Compare commits

..

7 Commits

Author SHA1 Message Date
waleed
034ad8331d cleanup up utils, share same deployed state as other tabs 2026-01-12 20:03:32 -08:00
waleed
56458625b7 consolidated tag-input, output select -> combobox, added tags for A2A 2026-01-12 19:36:25 -08:00
waleed
f93a946272 consolidated permissions utils 2026-01-12 15:46:17 -08:00
waleed
f2950c7060 readd migrations 2026-01-12 15:23:09 -08:00
waleed
88b4a1fe6e remove migrations 2026-01-12 15:11:26 -08:00
waleed
2512767dde feat(a2a): added a2a protocol 2026-01-12 15:10:42 -08:00
Emir Karabeg
b5f55b7c63 feat(a2a): a2a added 2026-01-12 15:05:46 -08:00
88 changed files with 17782 additions and 1317 deletions

View File

@@ -4061,6 +4061,31 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
<circle cx='544' cy='307' r='27' fill='currentColor' />
<circle cx='154' cy='307' r='27' fill='currentColor' />
<circle cx='706' cy='307' r='27' fill='currentColor' />
<circle cx='316' cy='307' r='27' fill='currentColor' />
<path
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
stroke='currentColor'
strokeWidth='48'
strokeLinecap='round'
/>
<path
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
fill='currentColor'
/>
<path
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
fill='currentColor'
/>
</svg>
)
}
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>

View File

@@ -4,6 +4,7 @@
import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AhrefsIcon,
AirtableIcon,
ApifyIcon,
@@ -126,6 +127,7 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
apify: ApifyIcon,

View File

@@ -0,0 +1,240 @@
---
title: A2A
description: Interact with external A2A-compatible agents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="a2a"
color="#4151B5"
/>
{/* MANUAL-CONTENT-START:intro */}
The A2A (Agent-to-Agent) protocol enables Sim to interact with external AI agents and systems that implement A2A-compatible APIs. With A2A, you can connect Sims automations and workflows to remote agents—such as LLM-powered bots, microservices, and other AI-based tools—using a standardized messaging format.
Using the A2A tools in Sim, you can:
- **Send Messages to External Agents**: Communicate directly with remote agents, providing prompts, commands, or data.
- **Receive and Stream Responses**: Get structured responses, artifacts, or real-time updates from the agent as the task progresses.
- **Continue Conversations or Tasks**: Carry on multi-turn conversations or workflows by referencing task and context IDs.
- **Integrate Third-Party AI and Automation**: Leverage external A2A-compatible services as part of your Sim workflows.
These features allow you to build advanced workflows that combine Sims native capabilities with the intelligence and automation of external AIs or custom agents. To use A2A integrations, youll need the external agents endpoint URL and, if required, an API key or credentials.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Use the A2A (Agent-to-Agent) protocol to interact with external AI agents.
## Tools
### `a2a_send_message`
Send a message to an external A2A-compatible agent.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `message` | string | Yes | Message to send to the agent |
| `taskId` | string | No | Task ID for continuing an existing task |
| `contextId` | string | No | Context ID for conversation continuity |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | The text response from the agent |
| `taskId` | string | Task ID for follow-up interactions |
| `contextId` | string | Context ID for conversation continuity |
| `state` | string | Task state |
| `artifacts` | array | Structured output artifacts |
| `history` | array | Full message history |
### `a2a_send_message_stream`
Send a message to an external A2A-compatible agent with real-time streaming.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `message` | string | Yes | Message to send to the agent |
| `taskId` | string | No | Task ID for continuing an existing task |
| `contextId` | string | No | Context ID for conversation continuity |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | The text response from the agent |
| `taskId` | string | Task ID for follow-up interactions |
| `contextId` | string | Context ID for conversation continuity |
| `state` | string | Task state |
| `artifacts` | array | Structured output artifacts |
| `history` | array | Full message history |
### `a2a_get_task`
Query the status of an existing A2A task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to query |
| `apiKey` | string | No | API key for authentication |
| `historyLength` | number | No | Number of history messages to include |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Task ID |
| `contextId` | string | Context ID |
| `state` | string | Task state |
| `artifacts` | array | Output artifacts |
| `history` | array | Message history |
### `a2a_cancel_task`
Cancel a running A2A task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to cancel |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `cancelled` | boolean | Whether cancellation was successful |
| `state` | string | Task state after cancellation |
### `a2a_get_agent_card`
Fetch the Agent Card (discovery document) for an A2A agent.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `apiKey` | string | No | API key for authentication \(if required\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Agent name |
| `description` | string | Agent description |
| `url` | string | Agent endpoint URL |
| `version` | string | Agent version |
| `capabilities` | object | Agent capabilities \(streaming, pushNotifications, etc.\) |
| `skills` | array | Skills the agent can perform |
| `defaultInputModes` | array | Default input modes \(text, file, data\) |
| `defaultOutputModes` | array | Default output modes \(text, file, data\) |
### `a2a_resubscribe`
Reconnect to an ongoing A2A task stream after connection interruption.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to resubscribe to |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Task ID |
| `contextId` | string | Context ID |
| `state` | string | Current task state |
| `isRunning` | boolean | Whether the task is still running |
| `artifacts` | array | Output artifacts |
| `history` | array | Message history |
### `a2a_set_push_notification`
Configure a webhook to receive task update notifications.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to configure notifications for |
| `webhookUrl` | string | Yes | HTTPS webhook URL to receive notifications |
| `token` | string | No | Token for webhook validation |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | Configured webhook URL |
| `token` | string | Token for webhook validation |
| `success` | boolean | Whether configuration was successful |
### `a2a_get_push_notification`
Get the push notification webhook configuration for a task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to get notification config for |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | Configured webhook URL |
| `token` | string | Token for webhook validation |
| `exists` | boolean | Whether a push notification config exists |
### `a2a_delete_push_notification`
Delete the push notification webhook configuration for a task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to delete notification config for |
| `pushNotificationConfigId` | string | No | Push notification configuration ID to delete \(optional - server can derive from taskId\) |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether deletion was successful |
## Notes
- Category: `tools`
- Type: `a2a`

View File

@@ -1,6 +1,7 @@
{
"pages": [
"index",
"a2a",
"ahrefs",
"airtable",
"apify",

View File

@@ -0,0 +1,269 @@
/**
* A2A Agent Card Endpoint
*
* Returns the Agent Card (discovery document) for an A2A agent.
* Also supports CRUD operations for managing agents.
*/
import { db } from '@sim/db'
import { a2aAgent, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
const logger = createLogger('A2AAgentCardAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
/**
* GET - Returns the Agent Card for discovery
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const [agent] = await db
.select({
agent: a2aAgent,
workflow: workflow,
})
.from(a2aAgent)
.innerJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
if (!agent.agent.isPublished) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
}
}
const agentCard = generateAgentCard(
{
id: agent.agent.id,
name: agent.agent.name,
description: agent.agent.description,
version: agent.agent.version,
capabilities: agent.agent.capabilities as AgentCapabilities,
skills: agent.agent.skills as AgentSkill[],
},
{
id: agent.workflow.id,
name: agent.workflow.name,
description: agent.workflow.description,
}
)
return NextResponse.json(agentCard, {
headers: {
'Content-Type': 'application/json',
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
},
})
} catch (error) {
logger.error('Error getting Agent Card:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT - Update an agent
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const body = await request.json()
let skills = body.skills ?? existingAgent.skills
if (body.skillTags !== undefined) {
const agentName = body.name ?? existingAgent.name
const agentDescription = body.description ?? existingAgent.description
skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
}
const [updatedAgent] = await db
.update(a2aAgent)
.set({
name: body.name ?? existingAgent.name,
description: body.description ?? existingAgent.description,
version: body.version ?? existingAgent.version,
capabilities: body.capabilities ?? existingAgent.capabilities,
skills,
authentication: body.authentication ?? existingAgent.authentication,
isPublished: body.isPublished ?? existingAgent.isPublished,
publishedAt:
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
.returning()
logger.info(`Updated A2A agent: ${agentId}`)
return NextResponse.json({ success: true, agent: updatedAgent })
} catch (error) {
logger.error('Error updating agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE - Delete an agent
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
logger.info(`Deleted A2A agent: ${agentId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Publish/unpublish an agent
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId })
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const body = await request.json()
const action = body.action as 'publish' | 'unpublish' | 'refresh'
if (action === 'publish') {
const [wf] = await db
.select({ isDeployed: workflow.isDeployed })
.from(workflow)
.where(eq(workflow.id, existingAgent.workflowId))
.limit(1)
if (!wf?.isDeployed) {
return NextResponse.json(
{ error: 'Workflow must be deployed before publishing agent' },
{ status: 400 }
)
}
await db
.update(a2aAgent)
.set({
isPublished: true,
publishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Published A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: true })
}
if (action === 'unpublish') {
await db
.update(a2aAgent)
.set({
isPublished: false,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Unpublished A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: false })
}
if (action === 'refresh') {
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
if (!workflowData) {
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
}
const [wf] = await db
.select({ name: workflow.name, description: workflow.description })
.from(workflow)
.where(eq(workflow.id, existingAgent.workflowId))
.limit(1)
const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description)
await db
.update(a2aAgent)
.set({
skills,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
return NextResponse.json({ success: true, skills })
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error with agent action:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,186 @@
/**
* A2A Agents List Endpoint
*
* List and create A2A agents for a workspace.
*/
import { db } from '@sim/db'
import { a2aAgent, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
import { sanitizeAgentName } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('A2AAgentsAPI')
export const dynamic = 'force-dynamic'
/**
* GET - List all A2A agents for a workspace
*/
export async function GET(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
const agents = await db
.select({
id: a2aAgent.id,
workspaceId: a2aAgent.workspaceId,
workflowId: a2aAgent.workflowId,
name: a2aAgent.name,
description: a2aAgent.description,
version: a2aAgent.version,
capabilities: a2aAgent.capabilities,
skills: a2aAgent.skills,
authentication: a2aAgent.authentication,
isPublished: a2aAgent.isPublished,
publishedAt: a2aAgent.publishedAt,
createdAt: a2aAgent.createdAt,
updatedAt: a2aAgent.updatedAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
isDeployed: workflow.isDeployed,
taskCount: sql<number>`(
SELECT COUNT(*)::int
FROM "a2a_task"
WHERE "a2a_task"."agent_id" = "a2a_agent"."id"
)`.as('task_count'),
})
.from(a2aAgent)
.leftJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
.where(eq(a2aAgent.workspaceId, workspaceId))
.orderBy(a2aAgent.createdAt)
logger.info(`Listed ${agents.length} A2A agents for workspace ${workspaceId}`)
return NextResponse.json({ success: true, agents })
} catch (error) {
logger.error('Error listing agents:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Create a new A2A agent from a workflow
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workspaceId, workflowId, name, description, capabilities, authentication, skillTags } =
body
if (!workspaceId || !workflowId) {
return NextResponse.json(
{ error: 'workspaceId and workflowId are required' },
{ status: 400 }
)
}
const [wf] = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
})
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.workspaceId, workspaceId)))
.limit(1)
if (!wf) {
return NextResponse.json(
{ error: 'Workflow not found or does not belong to workspace' },
{ status: 404 }
)
}
const [existing] = await db
.select({ id: a2aAgent.id })
.from(a2aAgent)
.where(and(eq(a2aAgent.workspaceId, workspaceId), eq(a2aAgent.workflowId, workflowId)))
.limit(1)
if (existing) {
return NextResponse.json(
{ error: 'An agent already exists for this workflow' },
{ status: 409 }
)
}
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData || !hasValidStartBlockInState(workflowData)) {
return NextResponse.json(
{ error: 'Workflow must have a Start block to be exposed as an A2A agent' },
{ status: 400 }
)
}
const skills = generateSkillsFromWorkflow(
name || wf.name,
description || wf.description,
skillTags
)
const agentId = uuidv4()
const agentName = name || sanitizeAgentName(wf.name)
const [agent] = await db
.insert(a2aAgent)
.values({
id: agentId,
workspaceId,
workflowId,
createdBy: auth.userId,
name: agentName,
description: description || wf.description,
version: '1.0.0',
capabilities: {
...A2A_DEFAULT_CAPABILITIES,
...capabilities,
},
skills,
authentication: authentication || {
schemes: ['bearer', 'apiKey'],
},
isPublished: false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
return NextResponse.json({ success: true, agent }, { status: 201 })
} catch (error) {
logger.error('Error creating agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
import { v4 as uuidv4 } from 'uuid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
/** A2A v0.3 JSON-RPC method names */
export const A2A_METHODS = {
MESSAGE_SEND: 'message/send',
MESSAGE_STREAM: 'message/stream',
TASKS_GET: 'tasks/get',
TASKS_CANCEL: 'tasks/cancel',
TASKS_RESUBSCRIBE: 'tasks/resubscribe',
PUSH_NOTIFICATION_SET: 'tasks/pushNotificationConfig/set',
PUSH_NOTIFICATION_GET: 'tasks/pushNotificationConfig/get',
PUSH_NOTIFICATION_DELETE: 'tasks/pushNotificationConfig/delete',
} as const
/** A2A v0.3 error codes */
export const A2A_ERROR_CODES = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
TASK_NOT_FOUND: -32001,
TASK_ALREADY_COMPLETE: -32002,
AGENT_UNAVAILABLE: -32003,
AUTHENTICATION_REQUIRED: -32004,
} as const
export interface JSONRPCRequest {
jsonrpc: '2.0'
id: string | number
method: string
params?: unknown
}
export interface JSONRPCResponse {
jsonrpc: '2.0'
id: string | number | null
result?: unknown
error?: {
code: number
message: string
data?: unknown
}
}
export interface MessageSendParams {
message: Message
configuration?: {
acceptedOutputModes?: string[]
historyLength?: number
pushNotificationConfig?: PushNotificationConfig
}
}
export interface TaskIdParams {
id: string
historyLength?: number
}
export interface PushNotificationSetParams {
id: string
pushNotificationConfig: PushNotificationConfig
}
export function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
return { jsonrpc: '2.0', id, result }
}
export function createError(
id: string | number | null,
code: number,
message: string,
data?: unknown
): JSONRPCResponse {
return { jsonrpc: '2.0', id, error: { code, message, data } }
}
export function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
if (!obj || typeof obj !== 'object') return false
const r = obj as Record<string, unknown>
return r.jsonrpc === '2.0' && typeof r.method === 'string' && r.id !== undefined
}
export function generateTaskId(): string {
return uuidv4()
}
export function createTaskStatus(state: TaskState): { state: TaskState; timestamp: string } {
return { state, timestamp: new Date().toISOString() }
}
export function formatTaskResponse(task: Task, historyLength?: number): Task {
if (historyLength !== undefined && task.history) {
return {
...task,
history: task.history.slice(-historyLength),
}
}
return task
}
export interface ExecuteRequestConfig {
workflowId: string
apiKey?: string | null
stream?: boolean
}
export interface ExecuteRequestResult {
url: string
headers: Record<string, string>
useInternalAuth: boolean
}
export async function buildExecuteRequest(
config: ExecuteRequestConfig
): Promise<ExecuteRequestResult> {
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
let useInternalAuth = false
if (config.apiKey) {
headers['X-API-Key'] = config.apiKey
} else {
const internalToken = await generateInternalToken()
headers.Authorization = `Bearer ${internalToken}`
useInternalAuth = true
}
if (config.stream) {
headers['X-Stream-Response'] = 'true'
}
return { url, headers, useInternalAuth }
}
export function extractAgentContent(executeResult: {
output?: { content?: string; [key: string]: unknown }
error?: string
}): string {
return (
executeResult.output?.content ||
(typeof executeResult.output === 'object'
? JSON.stringify(executeResult.output)
: String(executeResult.output || executeResult.error || 'Task completed'))
)
}
export function buildTaskResponse(params: {
taskId: string
contextId: string
state: TaskState
history: Message[]
artifacts?: Artifact[]
}): Task {
return {
kind: 'task',
id: params.taskId,
contextId: params.contextId,
status: createTaskStatus(params.state),
history: params.history,
artifacts: params.artifacts || [],
}
}

View File

@@ -96,7 +96,6 @@ const ChatMessageSchema = z.object({
})
)
.optional(),
commands: z.array(z.string()).optional(),
})
/**
@@ -132,7 +131,6 @@ export async function POST(req: NextRequest) {
provider,
conversationId,
contexts,
commands,
} = ChatMessageSchema.parse(body)
// Ensure we have a consistent user message ID for this request
const userMessageIdToUse = userMessageId || crypto.randomUUID()
@@ -460,7 +458,6 @@ export async function POST(req: NextRequest) {
...(integrationTools.length > 0 && { tools: integrationTools }),
...(baseTools.length > 0 && { baseTools }),
...(credentials && { credentials }),
...(commands && commands.length > 0 && { commands }),
}
try {

View File

@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { memory, permissions, workspace } from '@sim/db/schema'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MemoryByIdAPI')
@@ -29,46 +30,6 @@ const memoryPutBodySchema = z.object({
workspaceId: z.string().uuid('Invalid workspace ID format'),
})
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow) {
return { hasAccess: false, canWrite: false }
}
if (workspaceRow.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { hasAccess: false, canWrite: false }
}
return {
hasAccess: true,
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
}
}
async function validateMemoryAccess(
request: NextRequest,
workspaceId: string,
@@ -86,8 +47,8 @@ async function validateMemoryAccess(
}
}
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists || !access.hasAccess) {
return {
error: NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
@@ -96,7 +57,7 @@ async function validateMemoryAccess(
}
}
if (action === 'write' && !canWrite) {
if (action === 'write' && !access.canWrite) {
return {
error: NextResponse.json(
{ success: false, error: { message: 'Write access denied' } },

View File

@@ -1,56 +1,17 @@
import { db } from '@sim/db'
import { memory, permissions, workspace } from '@sim/db/schema'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, like } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MemoryAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow) {
return { hasAccess: false, canWrite: false }
}
if (workspaceRow.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { hasAccess: false, canWrite: false }
}
return {
hasAccess: true,
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
}
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
@@ -76,8 +37,14 @@ export async function GET(request: NextRequest) {
)
}
const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
@@ -155,15 +122,21 @@ export async function POST(request: NextRequest) {
)
}
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
)
}
if (!canWrite) {
if (!access.canWrite) {
return NextResponse.json(
{ success: false, error: { message: 'Write access denied to this workspace' } },
{ status: 403 }
@@ -282,15 +255,21 @@ export async function DELETE(request: NextRequest) {
)
}
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
)
}
if (!canWrite) {
if (!access.canWrite) {
return NextResponse.json(
{ success: false, error: { message: 'Write access denied to this workspace' } },
{ status: 403 }

View File

@@ -0,0 +1,85 @@
import type { Task } from '@a2a-js/sdk'
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('A2ACancelTaskAPI')
export const dynamic = 'force-dynamic'
const A2ACancelTaskSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A cancel task attempt`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2ACancelTaskSchema.parse(body)
logger.info(`[${requestId}] Canceling A2A task`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const task = (await client.cancelTask({ id: validatedData.taskId })) as Task
logger.info(`[${requestId}] Successfully canceled A2A task`, {
taskId: validatedData.taskId,
state: task.status.state,
})
return NextResponse.json({
success: true,
output: {
cancelled: true,
state: task.status.state,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid A2A cancel task request`, {
errors: error.errors,
})
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error canceling A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cancel task',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,95 @@
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ADeletePushNotificationAPI')
const A2ADeletePushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
pushNotificationConfigId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A delete push notification attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ADeletePushNotificationSchema.parse(body)
logger.info(`[${requestId}] Deleting A2A push notification config`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
pushNotificationConfigId: validatedData.pushNotificationConfigId,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
await client.deleteTaskPushNotificationConfig({
id: validatedData.taskId,
pushNotificationConfigId: validatedData.pushNotificationConfigId || validatedData.taskId,
})
logger.info(`[${requestId}] Push notification config deleted successfully`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete push notification',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,93 @@
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetAgentCardAPI')
const A2AGetAgentCardSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A get agent card attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2AGetAgentCardSchema.parse(body)
logger.info(`[${requestId}] Fetching Agent Card`, {
agentUrl: validatedData.agentUrl,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const agentCard = await client.getAgentCard()
logger.info(`[${requestId}] Agent Card fetched successfully`, {
agentName: agentCard.name,
})
return NextResponse.json({
success: true,
output: {
name: agentCard.name,
description: agentCard.description,
url: agentCard.url,
version: agentCard.protocolVersion,
capabilities: agentCard.capabilities,
skills: agentCard.skills,
defaultInputModes: agentCard.defaultInputModes,
defaultOutputModes: agentCard.defaultOutputModes,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error fetching Agent Card:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,116 @@
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetPushNotificationAPI')
const A2AGetPushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A get push notification attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A get push notification request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2AGetPushNotificationSchema.parse(body)
logger.info(`[${requestId}] Getting push notification config`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const result = await client.getTaskPushNotificationConfig({
id: validatedData.taskId,
})
if (!result || !result.pushNotificationConfig) {
logger.info(`[${requestId}] No push notification config found for task`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
exists: false,
},
})
}
logger.info(`[${requestId}] Push notification config retrieved successfully`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
url: result.pushNotificationConfig.url,
token: result.pushNotificationConfig.token,
exists: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
if (error instanceof Error && error.message.includes('not found')) {
logger.info(`[${requestId}] Task not found, returning exists: false`)
return NextResponse.json({
success: true,
output: {
exists: false,
},
})
}
logger.error(`[${requestId}] Error getting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get push notification',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,96 @@
import type { Task } from '@a2a-js/sdk'
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetTaskAPI')
const A2AGetTaskSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
historyLength: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A get task attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated A2A get task request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const validatedData = A2AGetTaskSchema.parse(body)
logger.info(`[${requestId}] Getting A2A task`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
historyLength: validatedData.historyLength,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const task = (await client.getTask({
id: validatedData.taskId,
historyLength: validatedData.historyLength,
})) as Task
logger.info(`[${requestId}] Successfully retrieved A2A task`, {
taskId: task.id,
state: task.status.state,
})
return NextResponse.json({
success: true,
output: {
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get task',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,121 @@
import type {
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatusUpdateEvent,
} from '@a2a-js/sdk'
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('A2AResubscribeAPI')
export const dynamic = 'force-dynamic'
const A2AResubscribeSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A resubscribe attempt`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2AResubscribeSchema.parse(body)
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const stream = client.resubscribeTask({ id: validatedData.taskId })
let taskId = validatedData.taskId
let contextId: string | undefined
let state: TaskState = 'working'
let content = ''
let artifacts: Artifact[] = []
let history: Message[] = []
for await (const event of stream) {
if (event.kind === 'message') {
const msg = event as Message
content = extractTextContent(msg)
taskId = msg.taskId || taskId
contextId = msg.contextId || contextId
state = 'completed'
} else if (event.kind === 'task') {
const task = event as Task
taskId = task.id
contextId = task.contextId
state = task.status.state
artifacts = task.artifacts || []
history = task.history || []
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
if (lastAgentMessage) {
content = extractTextContent(lastAgentMessage)
}
} else if ('status' in event) {
const statusEvent = event as TaskStatusUpdateEvent
state = statusEvent.status.state
} else if ('artifact' in event) {
const artifactEvent = event as TaskArtifactUpdateEvent
artifacts.push(artifactEvent.artifact)
}
}
logger.info(`[${requestId}] Successfully resubscribed to A2A task ${taskId}`)
return NextResponse.json({
success: true,
output: {
taskId,
contextId,
state,
isRunning: !isTerminalState(state),
artifacts,
history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid A2A resubscribe data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error resubscribing to A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to resubscribe',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,152 @@
import type {
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatusUpdateEvent,
} from '@a2a-js/sdk'
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageStreamAPI')
const A2ASendMessageStreamSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A send message stream attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A send message stream request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ASendMessageStreamSchema.parse(body)
logger.info(`[${requestId}] Sending A2A streaming message`, {
agentUrl: validatedData.agentUrl,
hasTaskId: !!validatedData.taskId,
hasContextId: !!validatedData.contextId,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
const stream = client.sendMessageStream({ message })
let taskId = ''
let contextId: string | undefined
let state: TaskState = 'working'
let content = ''
let artifacts: Artifact[] = []
let history: Message[] = []
for await (const event of stream) {
if (event.kind === 'message') {
const msg = event as Message
content = extractTextContent(msg)
taskId = msg.taskId || taskId
contextId = msg.contextId || contextId
state = 'completed'
} else if (event.kind === 'task') {
const task = event as Task
taskId = task.id
contextId = task.contextId
state = task.status.state
artifacts = task.artifacts || []
history = task.history || []
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
if (lastAgentMessage) {
content = extractTextContent(lastAgentMessage)
}
} else if ('status' in event) {
const statusEvent = event as TaskStatusUpdateEvent
state = statusEvent.status.state
} else if ('artifact' in event) {
const artifactEvent = event as TaskArtifactUpdateEvent
artifacts.push(artifactEvent.artifact)
}
}
logger.info(`[${requestId}] A2A streaming message completed`, {
taskId,
state,
artifactCount: artifacts.length,
})
return NextResponse.json({
success: isTerminalState(state) && state !== 'failed',
output: {
content,
taskId,
contextId,
state,
artifacts,
history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error in A2A streaming:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Streaming failed',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,128 @@
import type { Message, Task } from '@a2a-js/sdk'
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageAPI')
const A2ASendMessageSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A send message attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A send message request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ASendMessageSchema.parse(body)
logger.info(`[${requestId}] Sending A2A message`, {
agentUrl: validatedData.agentUrl,
hasTaskId: !!validatedData.taskId,
hasContextId: !!validatedData.contextId,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
const result = await client.sendMessage({ message })
if (result.kind === 'message') {
const responseMessage = result as Message
logger.info(`[${requestId}] A2A message sent successfully (message response)`)
return NextResponse.json({
success: true,
output: {
content: extractTextContent(responseMessage),
taskId: responseMessage.taskId || '',
contextId: responseMessage.contextId,
state: 'completed',
},
})
}
const task = result as Task
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
logger.info(`[${requestId}] A2A message sent successfully (task response)`, {
taskId: task.id,
state: task.status.state,
})
return NextResponse.json({
success: isTerminalState(task.status.state) && task.status.state !== 'failed',
output: {
content,
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error sending A2A message:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { ClientFactory } from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASetPushNotificationAPI')
const A2ASetPushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
webhookUrl: z.string().min(1, 'Webhook URL is required'),
token: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A set push notification attempt`, {
error: authResult.error || 'Authentication required',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2ASetPushNotificationSchema.parse(body)
logger.info(`[${requestId}] A2A set push notification request`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
webhookUrl: validatedData.webhookUrl,
})
const factory = new ClientFactory()
const client = await factory.createFromUrl(validatedData.agentUrl)
const result = await client.setTaskPushNotificationConfig({
taskId: validatedData.taskId,
pushNotificationConfig: {
url: validatedData.webhookUrl,
token: validatedData.token,
},
})
logger.info(`[${requestId}] A2A set push notification successful`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
url: result.pushNotificationConfig.url,
token: result.pushNotificationConfig.token,
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error setting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to set push notification',
},
{ status: 500 }
)
}
}

View File

@@ -215,10 +215,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowStateOverride,
} = validation.data
// For API key auth, the entire body is the input (except for our control fields)
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
auth.authType === 'api_key'
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
? (() => {
const {
selectedOutputs,
@@ -226,6 +226,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
stream,
useDraftState,
workflowStateOverride,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
...rest
} = body
return Object.keys(rest).length > 0 ? rest : validatedInput

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { workflow, workspace } from '@sim/db/schema'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowAPI')
@@ -36,13 +36,9 @@ export async function GET(request: Request) {
const userId = session.user.id
if (workspaceId) {
const workspaceExists = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.then((rows) => rows.length > 0)
const wsExists = await workspaceExists(workspaceId)
if (!workspaceExists) {
if (!wsExists) {
logger.warn(
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { apiKey, workspace } from '@sim/db/schema'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
@@ -9,7 +9,7 @@ import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceApiKeysAPI')
@@ -34,8 +34,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
@@ -10,7 +10,7 @@ import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceBYOKKeysAPI')
@@ -48,8 +48,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { environment, workspace, workspaceEnvironment } from '@sim/db/schema'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -7,7 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -33,8 +33,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
// Validate workspace exists
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -364,12 +364,30 @@ export default function PlaygroundPage() {
</VariantRow>
<VariantRow label='tag variants'>
<Tag value='valid@email.com' variant='default' />
<Tag value='secondary-tag' variant='secondary' />
<Tag value='invalid-email' variant='invalid' />
</VariantRow>
<VariantRow label='tag with remove'>
<Tag value='removable@tag.com' variant='default' onRemove={() => {}} />
<Tag value='secondary-removable' variant='secondary' onRemove={() => {}} />
<Tag value='invalid-removable' variant='invalid' onRemove={() => {}} />
</VariantRow>
<VariantRow label='secondary variant'>
<div className='w-80'>
<TagInput
items={[
{ value: 'workflow', isValid: true },
{ value: 'automation', isValid: true },
]}
onAdd={() => true}
onRemove={() => {}}
placeholder='Add tags'
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
/>
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-80'>
<TagInput

View File

@@ -888,7 +888,7 @@ export function Chat() {
selectedOutputs={selectedOutputs}
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder='Select outputs'
placeholder='Outputs'
align='end'
maxHeight={180}
/>

View File

@@ -1,16 +1,9 @@
'use client'
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, RepeatIcon, SplitIcon } from 'lucide-react'
import {
Badge,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
@@ -21,7 +14,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Renders a tag icon with background color.
* Renders a tag icon with background color for block section headers.
*
* @param icon - Either a letter string or a Lucide icon component
* @param color - Background color for the icon container
@@ -62,14 +55,9 @@ interface OutputSelectProps {
placeholder?: string
/** Whether to emit output IDs or labels in onOutputSelect callback */
valueMode?: 'id' | 'label'
/**
* When true, renders the underlying popover content inline instead of in a portal.
* Useful when used inside dialogs or other portalled components that manage scroll locking.
*/
disablePopoverPortal?: boolean
/** Alignment of the popover relative to the trigger */
/** Alignment of the dropdown relative to the trigger */
align?: 'start' | 'end' | 'center'
/** Maximum height of the popover content in pixels */
/** Maximum height of the dropdown content in pixels */
maxHeight?: number
}
@@ -90,14 +78,9 @@ export function OutputSelect({
disabled = false,
placeholder = 'Select outputs',
valueMode = 'id',
disablePopoverPortal = false,
align = 'start',
maxHeight = 200,
}: OutputSelectProps) {
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
const subBlockValues = useSubBlockStore((state) =>
@@ -206,21 +189,10 @@ export function OutputSelect({
shouldUseBaseline,
])
/**
* Checks if an output is currently selected by comparing both ID and label
* @param o - The output object to check
* @returns True if the output is selected, false otherwise
*/
const isSelectedValue = useCallback(
(o: { id: string; label: string }) =>
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label),
[selectedOutputs]
)
/**
* Gets display text for selected outputs
*/
const selectedOutputsDisplayText = useMemo(() => {
const selectedDisplayText = useMemo(() => {
if (!selectedOutputs || selectedOutputs.length === 0) {
return placeholder
}
@@ -234,19 +206,27 @@ export function OutputSelect({
}
if (validOutputs.length === 1) {
const output = workflowOutputs.find(
(o) => o.id === validOutputs[0] || o.label === validOutputs[0]
)
return output?.label || placeholder
return '1 output'
}
return `${validOutputs.length} outputs`
}, [selectedOutputs, workflowOutputs, placeholder])
/**
* Groups outputs by block and sorts by distance from starter block
* Gets the background color for a block output based on its type
* @param blockType - The type of the block
* @returns The hex color code for the block
*/
const groupedOutputs = useMemo(() => {
const getOutputColor = (blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}
/**
* Groups outputs by block and sorts by distance from starter block.
* Returns ComboboxOptionGroup[] for use with Combobox.
*/
const comboboxGroups = useMemo((): ComboboxOptionGroup[] => {
const groups: Record<string, typeof workflowOutputs> = {}
const blockDistances: Record<string, number> = {}
const edges = useWorkflowStore.getState().edges
@@ -283,242 +263,75 @@ export function OutputSelect({
groups[output.blockName].push(output)
})
return Object.entries(groups)
const sortedGroups = Object.entries(groups)
.map(([blockName, outputs]) => ({
blockName,
outputs,
distance: blockDistances[outputs[0]?.blockId] || 0,
}))
.sort((a, b) => b.distance - a.distance)
.reduce(
(acc, { blockName, outputs }) => {
acc[blockName] = outputs
return acc
},
{} as Record<string, typeof workflowOutputs>
)
}, [workflowOutputs, blocks])
/**
* Gets the background color for a block output based on its type
* @param blockId - The block ID (unused but kept for future extensibility)
* @param blockType - The type of the block
* @returns The hex color code for the block
*/
const getOutputColor = (blockId: string, blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}
return sortedGroups.map(({ blockName, outputs }) => {
const firstOutput = outputs[0]
const blockConfig = getBlock(firstOutput.blockType)
const blockColor = getOutputColor(firstOutput.blockType)
/**
* Flattened outputs for keyboard navigation
*/
const flattenedOutputs = useMemo(() => {
return Object.values(groupedOutputs).flat()
}, [groupedOutputs])
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
.charAt(0)
.toUpperCase()
/**
* Handles output selection by toggling the selected state
* @param value - The output label to toggle
*/
const handleOutputSelection = useCallback(
(value: string) => {
const emittedValue =
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
const index = selectedOutputs.indexOf(emittedValue)
const newSelectedOutputs =
index === -1
? [...new Set([...selectedOutputs, emittedValue])]
: selectedOutputs.filter((id) => id !== emittedValue)
onOutputSelect(newSelectedOutputs)
},
[valueMode, workflowOutputs, selectedOutputs, onOutputSelect]
)
/**
* Handles keyboard navigation within the output list
* Supports ArrowUp, ArrowDown, Enter, and Escape keys
*/
useEffect(() => {
if (!open || flattenedOutputs.length === 0) return
const handleKeyboardEvent = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setHighlightedIndex((prev) => {
if (prev === -1 || prev >= flattenedOutputs.length - 1) {
return 0
}
return prev + 1
})
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setHighlightedIndex((prev) => {
if (prev <= 0) {
return flattenedOutputs.length - 1
}
return prev - 1
})
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
setHighlightedIndex((currentIndex) => {
if (currentIndex >= 0 && currentIndex < flattenedOutputs.length) {
handleOutputSelection(flattenedOutputs[currentIndex].label)
}
return currentIndex
})
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
break
if (blockConfig?.icon) {
blockIcon = blockConfig.icon
} else if (firstOutput.blockType === 'loop') {
blockIcon = RepeatIcon
} else if (firstOutput.blockType === 'parallel') {
blockIcon = SplitIcon
}
}
window.addEventListener('keydown', handleKeyboardEvent, true)
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
}, [open, flattenedOutputs, handleOutputSelection])
/**
* Reset highlighted index when popover opens/closes
*/
useEffect(() => {
if (open) {
const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output))
setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1)
} else {
setHighlightedIndex(-1)
}
}, [open, flattenedOutputs, isSelectedValue])
/**
* Scroll highlighted item into view
*/
useEffect(() => {
if (highlightedIndex >= 0 && popoverRef.current) {
const highlightedElement = popoverRef.current.querySelector(
`[data-option-index="${highlightedIndex}"]`
)
if (highlightedElement) {
highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
return {
sectionElement: (
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
),
items: outputs.map((output) => ({
label: output.path,
value: valueMode === 'label' ? output.label : output.id,
})),
}
}
}, [highlightedIndex])
})
}, [workflowOutputs, blocks, valueMode])
/**
* Closes popover when clicking outside
* Normalize selected values to match the valueMode
*/
useEffect(() => {
if (!open) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
const insideTrigger = triggerRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideTrigger && !insidePopover) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const normalizedSelectedValues = useMemo(() => {
return selectedOutputs
.map((val) => {
// Find the output that matches either id or label
const output = workflowOutputs.find((o) => o.id === val || o.label === val)
if (!output) return null
// Return in the format matching valueMode
return valueMode === 'label' ? output.label : output.id
})
.filter((v): v is string => v !== null)
}, [selectedOutputs, workflowOutputs, valueMode])
return (
<Popover open={open} variant='default'>
<PopoverTrigger asChild>
<div ref={triggerRef} className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
title='Select outputs'
aria-expanded={open}
onMouseDown={(e) => {
if (disabled || workflowOutputs.length === 0) return
e.stopPropagation()
setOpen((prev) => !prev)
}}
>
<span className='whitespace-nowrap text-[12px]'>{selectedOutputsDisplayText}</span>
</Badge>
</div>
</PopoverTrigger>
<PopoverContent
ref={popoverRef}
side='bottom'
align={align}
sideOffset={4}
maxHeight={maxHeight}
maxWidth={300}
minWidth={160}
border
disablePortal={disablePopoverPortal}
>
<div className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
const firstOutput = outputs[0]
const blockConfig = getBlock(firstOutput.blockType)
const blockColor = getOutputColor(firstOutput.blockId, firstOutput.blockType)
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
.charAt(0)
.toUpperCase()
if (blockConfig?.icon) {
blockIcon = blockConfig.icon
} else if (firstOutput.blockType === 'loop') {
blockIcon = RepeatIcon
} else if (firstOutput.blockType === 'parallel') {
blockIcon = SplitIcon
}
return (
<div key={blockName}>
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
<div className='flex flex-col gap-[2px]'>
{outputs.map((output, localIndex) => {
const globalIndex = startIndex + localIndex
const isHighlighted = globalIndex === highlightedIndex
return (
<PopoverItem
key={output.id}
active={isSelectedValue(output) || isHighlighted}
data-option-index={globalIndex}
onClick={() => handleOutputSelection(output.label)}
onMouseEnter={() => setHighlightedIndex(globalIndex)}
>
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
</PopoverItem>
)
})}
</div>
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
</div>
)
})}
</div>
</PopoverContent>
</Popover>
<Combobox
size='sm'
className='!w-fit !py-[2px] [&>svg]:!ml-[4px] [&>svg]:!h-3 [&>svg]:!w-3 [&>span]:!text-[var(--text-secondary)] min-w-[100px] rounded-[6px] bg-transparent px-[9px] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-6)] dark:hover:bg-transparent [&>span]:text-center'
groups={comboboxGroups}
options={[]}
multiSelect
multiSelectValues={normalizedSelectedValues}
onMultiSelectChange={onOutputSelect}
placeholder={selectedDisplayText}
disabled={disabled || workflowOutputs.length === 0}
align={align}
maxHeight={maxHeight}
dropdownWidth={220}
/>
)
}

View File

@@ -326,8 +326,8 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
),
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-3 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
<div className='my-4 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-sm'>
{children}
</table>
</div>
@@ -346,12 +346,12 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
<th className='border-[var(--border-1)] border-r px-4 py-2 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
<td className='break-words border-[var(--border-1)] border-r px-4 py-2 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
{children}
</td>
),

View File

@@ -246,7 +246,7 @@ export function ThinkingBlock({
)}
>
{/* Render markdown during streaming with thinking text styling */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
<CopilotMarkdownRenderer content={content} />
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
</div>
@@ -286,7 +286,7 @@ export function ThinkingBlock({
)}
>
{/* Use markdown renderer for completed content */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
<CopilotMarkdownRenderer content={content} />
</div>
</div>

View File

@@ -346,18 +346,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const contexts: any[] = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[])
: []
// Build tokens with their prefixes (@ for mentions, / for commands)
const tokens = contexts
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
.map((c) => {
const prefix = c?.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
if (!tokens.length) return text
const labels = contexts
.filter((c) => c?.kind !== 'current_workflow')
.map((c) => c?.label)
.filter(Boolean) as string[]
if (!labels.length) return text
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g')
const nodes: React.ReactNode[] = []
let lastIndex = 0

View File

@@ -2595,23 +2595,16 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
}
}
// For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it)
const isEditWorkflow = toolCall.name === 'edit_workflow'
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
return (
<div className='w-full'>
{!hideTextForEditWorkflow && (
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
)}
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-1.5'>

View File

@@ -3,4 +3,3 @@ export { ContextPills } from './context-pills/context-pills'
export { MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'
export { SlashMenu } from './slash-menu/slash-menu'

View File

@@ -1,247 +0,0 @@
'use client'
import { useMemo } from 'react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
} from '@/components/emcn'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
/**
* Top-level slash command options
*/
const TOP_LEVEL_COMMANDS = [
{ id: 'plan', label: 'plan' },
{ id: 'debug', label: 'debug' },
{ id: 'fast', label: 'fast' },
{ id: 'superagent', label: 'superagent' },
{ id: 'deploy', label: 'deploy' },
] as const
/**
* Web submenu commands
*/
const WEB_COMMANDS = [
{ id: 'search', label: 'search' },
{ id: 'research', label: 'research' },
{ id: 'crawl', label: 'crawl' },
{ id: 'read', label: 'read' },
{ id: 'scrape', label: 'scrape' },
] as const
/**
* All command labels for filtering
*/
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
interface SlashMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
message: string
onSelectCommand: (command: string) => void
}
/**
* SlashMenu component for slash command dropdown.
* Shows command options when user types '/'.
*
* @param props - Component props
* @returns Rendered slash menu
*/
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
const {
mentionMenuRef,
menuListRef,
getActiveSlashQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
} = mentionMenu
/**
* Get the current query string after /
*/
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
/**
* Filter commands based on query (search across all commands when there's a query)
*/
const filteredCommands = useMemo(() => {
if (!currentQuery) return null // Show folder view when no query
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
}, [currentQuery])
// Show aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
// Compute caret viewport position via mirror technique for precise anchoring
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = text.substring(0, caretPosition)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
return {
left: textareaRect.left + leftOffset,
top: textareaRect.top + topOffset,
}
}
const caretPos = getCaretPos()
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
// Decide preferred side based on available space
const margin = 8
const spaceAbove = caretViewport.top - margin
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
// Check if we're in folder navigation mode (no query, not in submenu)
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
return (
<Popover
open={true}
onOpenChange={() => {
/* controlled externally */
}}
>
<PopoverAnchor asChild>
<div
style={{
position: 'fixed',
top: `${caretViewport.top}px`,
left: `${caretViewport.left}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
<PopoverContent
ref={mentionMenuRef}
side={side}
align='start'
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{
width: `180px`,
}}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverBackButton />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor === 'Web' ? (
// Web submenu view
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
No commands found
</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
// Folder navigation view
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setOpenSubmenuFor('Web')}
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.label)}>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -63,9 +63,6 @@ export function useContextManagement({ message }: UseContextManagementProps) {
if (c.kind === 'docs') {
return true // Only one docs context allowed
}
if (c.kind === 'slash_command' && 'command' in context && 'command' in c) {
return c.command === (context as any).command
}
}
return false
@@ -106,8 +103,6 @@ export function useContextManagement({ message }: UseContextManagementProps) {
return (c as any).executionId !== (contextToRemove as any).executionId
case 'docs':
return false // Remove docs (only one docs context)
case 'slash_command':
return (c as any).command !== (contextToRemove as any).command
default:
return c.label !== contextToRemove.label
}
@@ -123,7 +118,7 @@ export function useContextManagement({ message }: UseContextManagementProps) {
}, [])
/**
* Synchronizes selected contexts with inline @label or /label tokens in the message.
* Synchronizes selected contexts with inline @label tokens in the message.
* Removes contexts whose labels are no longer present in the message.
*/
useEffect(() => {
@@ -135,14 +130,17 @@ export function useContextManagement({ message }: UseContextManagementProps) {
setSelectedContexts((prev) => {
if (prev.length === 0) return prev
const filtered = prev.filter((c) => {
if (!c.label) return false
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
const token = ` ${prefix}${c.label} `
return message.includes(token)
})
const presentLabels = new Set<string>()
const labels = prev.map((c) => c.label).filter(Boolean)
for (const label of labels) {
const token = ` @${label} `
if (message.includes(token)) {
presentLabels.add(label)
}
}
const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label))
return filtered.length === prev.length ? prev : filtered
})
}, [message])

View File

@@ -113,62 +113,6 @@ export function useMentionMenu({
[message, selectedContexts]
)
/**
* Finds active slash command query at the given position
*
* @param pos - Position in the text to check
* @param textOverride - Optional text override (for checking during input)
* @returns Active slash query object or null if no active slash command
*/
const getActiveSlashQueryAtPosition = useCallback(
(pos: number, textOverride?: string) => {
const text = textOverride ?? message
const before = text.slice(0, pos)
const slashIndex = before.lastIndexOf('/')
if (slashIndex === -1) return null
// Ensure '/' starts a token (start or whitespace before)
if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null
// Check if this '/' is part of a completed slash token ( /command )
if (selectedContexts.length > 0) {
const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[]
for (const label of labels) {
// Space-wrapped token: " /label "
const token = ` /${label} `
let fromIndex = 0
while (fromIndex <= text.length) {
const idx = text.indexOf(token, fromIndex)
if (idx === -1) break
const tokenStart = idx
const tokenEnd = idx + token.length
const slashPositionInToken = idx + 1 // position of / in " /label "
if (slashIndex === slashPositionInToken) {
return null
}
if (pos > tokenStart && pos < tokenEnd) {
return null
}
fromIndex = tokenEnd
}
}
}
const segment = before.slice(slashIndex + 1)
// Close the popup if user types space immediately after /
if (segment.length > 0 && /^\s/.test(segment)) {
return null
}
return { query: segment, start: slashIndex, end: pos }
},
[message, selectedContexts]
)
/**
* Gets the submenu query text
*
@@ -273,40 +217,6 @@ export function useMentionMenu({
[message, getActiveMentionQueryAtPosition, onMessageChange]
)
/**
* Replaces active slash command with a label
*
* @param label - Label to replace the slash command with
* @returns True if replacement was successful, false if no active slash command found
*/
const replaceActiveSlashWith = useCallback(
(label: string) => {
const textarea = textareaRef.current
if (!textarea) return false
const pos = textarea.selectionStart ?? message.length
const active = getActiveSlashQueryAtPosition(pos)
if (!active) return false
const before = message.slice(0, active.start)
const after = message.slice(active.end)
// Always include leading space, avoid duplicate if one exists
const needsLeadingSpace = !before.endsWith(' ')
const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} `
const next = `${before}${insertion}${after}`
onMessageChange(next)
setTimeout(() => {
const cursorPos = before.length + insertion.length
textarea.setSelectionRange(cursorPos, cursorPos)
textarea.focus()
}, 0)
return true
},
[message, getActiveSlashQueryAtPosition, onMessageChange]
)
/**
* Scrolls active item into view in the menu
*
@@ -394,12 +304,10 @@ export function useMentionMenu({
// Operations
getCaretPos,
getActiveMentionQueryAtPosition,
getActiveSlashQueryAtPosition,
getSubmenuQuery,
resetActiveMentionQuery,
insertAtCursor,
replaceActiveMentionWith,
replaceActiveSlashWith,
scrollActiveItemIntoView,
closeMentionMenu,
}

View File

@@ -39,7 +39,7 @@ export function useMentionTokens({
setSelectedContexts,
}: UseMentionTokensProps) {
/**
* Computes all mention ranges in the message (both @mentions and /commands)
* Computes all mention ranges in the message
*
* @returns Array of mention ranges sorted by start position
*/
@@ -55,13 +55,8 @@ export function useMentionTokens({
const uniqueLabels = Array.from(new Set(labels))
for (const label of uniqueLabels) {
// Find matching context to determine if it's a slash command
const matchingContext = selectedContexts.find((c) => c.label === label)
const isSlashCommand = matchingContext?.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
// Space-wrapped token: " @label " or " /label " (search from start)
const token = ` ${prefix}${label} `
// Space-wrapped token: " @label " (search from start)
const token = ` @${label} `
let fromIndex = 0
while (fromIndex <= message.length) {
const idx = message.indexOf(token, fromIndex)

View File

@@ -21,7 +21,6 @@ import {
MentionMenu,
ModelSelector,
ModeSelector,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
@@ -124,7 +123,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const [isNearTop, setIsNearTop] = useState(false)
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
// Controlled vs uncontrolled message state
const message = controlledValue !== undefined ? controlledValue : internalMessage
@@ -372,113 +370,20 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [onAbort, isLoading])
const handleSlashCommandSelect = useCallback(
(command: string) => {
// Capitalize the command for display
const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1)
// Replace the active slash query with the capitalized command
mentionMenu.replaceActiveSlashWith(capitalizedCommand)
// Add as a context so it gets highlighted
contextManagement.addContext({
kind: 'slash_command',
command,
label: capitalizedCommand,
})
setShowSlashMenu(false)
mentionMenu.textareaRef.current?.focus()
},
[mentionMenu, contextManagement]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Escape key handling
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
if (e.key === 'Escape' && mentionMenu.showMentionMenu) {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
} else {
mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
}
return
}
// Arrow navigation in slash menu
if (showSlashMenu) {
const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy']
const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape']
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (mentionMenu.openSubmenuFor === 'Web') {
// Navigate in Web submenu
const last = WEB_COMMANDS.length - 1
mentionMenu.setSubmenuActiveIndex((prev) => {
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
// Navigate in filtered view
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const last = Math.max(0, filtered.length - 1)
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else {
// Navigate in folder view (top-level + Web folder)
const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder
const last = totalItems - 1
mentionMenu.setMentionActiveIndex((prev) => {
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
}
return
}
// Arrow right to enter Web submenu
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
// Check if Web folder is selected (it's after all top-level commands)
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
}
}
return
}
// Arrow left to exit submenu
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
}
return
}
}
// Arrow navigation in mention menu
if (mentionKeyboard.handleArrowNavigation(e)) return
if (mentionKeyboard.handleArrowRight(e)) return
@@ -487,41 +392,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// Enter key handling
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (showSlashMenu) {
const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy']
const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape']
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
if (mentionMenu.openSubmenuFor === 'Web') {
// Select from Web submenu
const selectedCommand = WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
// Select from filtered view
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
}
} else {
// Folder navigation view
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
// Top-level command selected
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
// Web folder selected - open it
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
}
}
return
}
if (!mentionMenu.showMentionMenu) {
handleSubmit()
} else {
@@ -599,15 +469,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}
},
[
mentionMenu,
mentionKeyboard,
handleSubmit,
handleSlashCommandSelect,
message,
mentionTokensWithContext,
showSlashMenu,
]
[mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext]
)
const handleInputChange = useCallback(
@@ -619,14 +481,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (disableMentions) return
const caret = e.target.selectionStart ?? newValue.length
const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
// Check for @ mention trigger
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
// Check for / slash command trigger
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
if (activeMention) {
setShowSlashMenu(false)
if (active) {
mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false)
if (mentionMenu.openSubmenuFor) {
@@ -635,17 +492,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu.setMentionActiveIndex(0)
mentionMenu.setSubmenuActiveIndex(0)
}
} else if (activeSlash) {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(false)
}
},
[setMessage, mentionMenu, disableMentions]
@@ -692,32 +542,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
const handleOpenSlashMenu = useCallback(() => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
textarea.focus()
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
const insertText = needsSpaceBefore ? ' /' : '/'
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const before = message.slice(0, start)
const after = message.slice(end)
const next = `${before}${insertText}${after}`
setMessage(next)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
@@ -819,18 +643,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<AtSign className='h-3 w-3' strokeWidth={1.75} />
</Badge>
<Badge
variant='outline'
onClick={handleOpenSlashMenu}
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
)}
>
<span className='flex h-3 w-3 items-center justify-center text-[11px] font-medium leading-none'>/</span>
</Badge>
{/* Selected Context Pills */}
<ContextPills
contexts={contextManagement.selectedContexts}
@@ -905,18 +717,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
/>,
document.body
)}
{/* Slash Menu Portal */}
{!disableMentions &&
showSlashMenu &&
createPortal(
<SlashMenu
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
/>,
document.body
)}
</div>
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}

View File

@@ -0,0 +1,921 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Check, Clipboard } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Checkbox,
Code,
Combobox,
type ComboboxOption,
Input,
Label,
TagInput,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
import {
a2aAgentKeys,
useA2AAgentByWorkflow,
useCreateA2AAgent,
useDeleteA2AAgent,
usePublishA2AAgent,
useUpdateA2AAgent,
} from '@/hooks/queries/a2a/agents'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('A2ADeploy')
interface InputFormatField {
id?: string
name?: string
type?: string
value?: unknown
collapsed?: boolean
}
/**
* Check if a description is a default/placeholder value that should be filtered out
*/
function isDefaultDescription(desc: string | null | undefined, workflowName: string): boolean {
if (!desc) return true
const normalized = desc.toLowerCase().trim()
return (
normalized === '' || normalized === 'new workflow' || normalized === workflowName.toLowerCase()
)
}
type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript'
const LANGUAGE_LABELS: Record<CodeLanguage, string> = {
curl: 'cURL',
python: 'Python',
javascript: 'JavaScript',
typescript: 'TypeScript',
}
const LANGUAGE_SYNTAX: Record<CodeLanguage, 'python' | 'javascript' | 'json'> = {
curl: 'javascript',
python: 'python',
javascript: 'javascript',
typescript: 'javascript',
}
interface A2aDeployProps {
workflowId: string
workflowName: string
workflowDescription?: string | null
isDeployed: boolean
workflowNeedsRedeployment?: boolean
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onAgentExistsChange?: (exists: boolean) => void
onPublishedChange?: (published: boolean) => void
onNeedsRepublishChange?: (needsRepublish: boolean) => void
onDeployWorkflow?: () => Promise<void>
}
type AuthScheme = 'none' | 'apiKey'
export function A2aDeploy({
workflowId,
workflowName,
workflowDescription,
isDeployed,
workflowNeedsRedeployment,
onSubmittingChange,
onCanSaveChange,
onAgentExistsChange,
onPublishedChange,
onNeedsRepublishChange,
onDeployWorkflow,
}: A2aDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const queryClient = useQueryClient()
const { data: existingAgent, isLoading, refetch } = useA2AAgentByWorkflow(workspaceId, workflowId)
const createAgent = useCreateA2AAgent()
const updateAgent = useUpdateA2AAgent()
const deleteAgent = useDeleteA2AAgent()
const publishAgent = usePublishA2AAgent()
// Start block input field detection
const blocks = useWorkflowStore((state) => state.blocks)
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const startBlockId = useMemo(() => {
if (!blocks || Object.keys(blocks).length === 0) return null
const candidate = TriggerUtils.findStartBlock(blocks, 'api')
if (!candidate || candidate.path !== StartBlockPath.UNIFIED) return null
return candidate.blockId
}, [blocks])
const startBlockInputFormat = useSubBlockStore((state) => {
if (!workflowId || !startBlockId) return null
const workflowValues = state.workflowValues[workflowId]
const fromStore = workflowValues?.[startBlockId]?.inputFormat
if (fromStore !== undefined) return fromStore
const startBlock = blocks[startBlockId]
return startBlock?.subBlocks?.inputFormat?.value ?? null
})
const missingFields = useMemo(() => {
if (!startBlockId) return { input: false, data: false, files: false, any: false }
const normalizedFields = normalizeInputFormatValue(startBlockInputFormat)
const existingNames = new Set(
normalizedFields
.map((field) => field.name)
.filter((n): n is string => typeof n === 'string' && n.trim() !== '')
.map((n) => n.trim().toLowerCase())
)
const missing = {
input: !existingNames.has('input'),
data: !existingNames.has('data'),
files: !existingNames.has('files'),
any: false,
}
missing.any = missing.input || missing.data || missing.files
return missing
}, [startBlockId, startBlockInputFormat])
const handleAddA2AInputs = useCallback(() => {
if (!startBlockId) return
const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
const newFields: InputFormatField[] = []
// Add input field if missing (for TextPart)
if (missingFields.input) {
newFields.push({
id: crypto.randomUUID(),
name: 'input',
type: 'string',
value: '',
collapsed: false,
})
}
// Add data field if missing (for DataPart)
if (missingFields.data) {
newFields.push({
id: crypto.randomUUID(),
name: 'data',
type: 'object',
value: '',
collapsed: false,
})
}
// Add files field if missing (for FilePart)
if (missingFields.files) {
newFields.push({
id: crypto.randomUUID(),
name: 'files',
type: 'files',
value: '',
collapsed: false,
})
}
if (newFields.length > 0) {
const updatedFields = [...newFields, ...normalizedExisting]
// Use collaborative update to ensure proper socket sync
collaborativeSetSubblockValue(startBlockId, 'inputFormat', updatedFields)
logger.info(
`Added A2A input fields to Start block: ${newFields.map((f) => f.name).join(', ')}`
)
}
}, [startBlockId, startBlockInputFormat, missingFields, collaborativeSetSubblockValue])
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [authScheme, setAuthScheme] = useState<AuthScheme>('apiKey')
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false)
const [skillTags, setSkillTags] = useState<string[]>(['workflow', 'automation'])
const [language, setLanguage] = useState<CodeLanguage>('curl')
const [copied, setCopied] = useState(false)
useEffect(() => {
if (existingAgent) {
setName(existingAgent.name)
// Filter out default descriptions to encourage user to enter a meaningful one
const savedDesc = existingAgent.description || ''
setDescription(isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc)
setPushNotificationsEnabled(existingAgent.capabilities?.pushNotifications ?? false)
const schemes = existingAgent.authentication?.schemes || []
if (schemes.includes('apiKey')) {
setAuthScheme('apiKey')
} else {
setAuthScheme('none')
}
// Extract tags from first skill if available
const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
const savedTags = skills?.[0]?.tags
setSkillTags(savedTags?.length ? savedTags : ['workflow', 'automation'])
} else {
setName(workflowName)
// Filter out default descriptions to encourage user to enter a meaningful one
setDescription(
isDefaultDescription(workflowDescription, workflowName) ? '' : workflowDescription || ''
)
setAuthScheme('apiKey')
setPushNotificationsEnabled(false)
setSkillTags(['workflow', 'automation'])
}
}, [existingAgent, workflowName, workflowDescription])
useEffect(() => {
onAgentExistsChange?.(!!existingAgent)
}, [existingAgent, onAgentExistsChange])
useEffect(() => {
onPublishedChange?.(existingAgent?.isPublished ?? false)
}, [existingAgent?.isPublished, onPublishedChange])
// Detect form changes compared to saved agent state
const hasFormChanges = useMemo(() => {
if (!existingAgent) return false
const savedSchemes = existingAgent.authentication?.schemes || []
const savedAuthScheme = savedSchemes.includes('apiKey') ? 'apiKey' : 'none'
// Compare description, filtering out default values for both
const savedDesc = existingAgent.description || ''
const normalizedSavedDesc = isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc
// Compare tags
const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
const savedTags = skills?.[0]?.tags || ['workflow', 'automation']
const tagsChanged =
skillTags.length !== savedTags.length || skillTags.some((t, i) => t !== savedTags[i])
return (
name !== existingAgent.name ||
description !== normalizedSavedDesc ||
pushNotificationsEnabled !== (existingAgent.capabilities?.pushNotifications ?? false) ||
authScheme !== savedAuthScheme ||
tagsChanged
)
}, [
existingAgent,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workflowName,
])
// Detect if workflow has pending changes not yet deployed
// This aligns with the General tab's "needs redeployment" detection
const hasWorkflowChanges = useMemo(() => {
if (!existingAgent) return false
return !!workflowNeedsRedeployment
}, [existingAgent, workflowNeedsRedeployment])
const needsRepublish = existingAgent && (hasFormChanges || hasWorkflowChanges)
useEffect(() => {
onNeedsRepublishChange?.(!!needsRepublish)
}, [needsRepublish, onNeedsRepublishChange])
const authSchemeOptions: ComboboxOption[] = useMemo(
() => [
{ label: 'API Key', value: 'apiKey' },
{ label: 'None (Public)', value: 'none' },
],
[]
)
// Require both name and description to publish
const canSave = name.trim().length > 0 && description.trim().length > 0
useEffect(() => {
onCanSaveChange?.(canSave)
}, [canSave, onCanSaveChange])
const isSubmitting =
createAgent.isPending ||
updateAgent.isPending ||
deleteAgent.isPending ||
publishAgent.isPending
useEffect(() => {
onSubmittingChange?.(isSubmitting)
}, [isSubmitting, onSubmittingChange])
const handleCreateOrUpdate = useCallback(async () => {
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
stateTransitionHistory: true,
}
const authentication: AgentAuthentication = {
schemes: authScheme === 'none' ? ['none'] : [authScheme],
}
try {
if (existingAgent) {
await updateAgent.mutateAsync({
agentId: existingAgent.id,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
} else {
await createAgent.mutateAsync({
workspaceId,
workflowId,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
}
queryClient.invalidateQueries({
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId],
})
} catch (error) {
logger.error('Failed to save A2A agent:', error)
}
}, [
existingAgent,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
createAgent,
updateAgent,
queryClient,
])
const handlePublish = useCallback(async () => {
if (!existingAgent) return
try {
await publishAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
action: 'publish',
})
refetch()
} catch (error) {
logger.error('Failed to publish A2A agent:', error)
}
}, [existingAgent, workspaceId, publishAgent, refetch])
const handleUnpublish = useCallback(async () => {
if (!existingAgent) return
try {
await publishAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
action: 'unpublish',
})
refetch()
} catch (error) {
logger.error('Failed to unpublish A2A agent:', error)
}
}, [existingAgent, workspaceId, publishAgent, refetch])
const handleDelete = useCallback(async () => {
if (!existingAgent) return
try {
await deleteAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
})
setName(workflowName)
setDescription(workflowDescription || '')
} catch (error) {
logger.error('Failed to delete A2A agent:', error)
}
}, [existingAgent, workspaceId, deleteAgent, workflowName, workflowDescription])
const handleCopyEndpoint = useCallback(() => {
if (!existingAgent) return
const copyEndpoint = `${getBaseUrl()}/api/a2a/serve/${existingAgent.id}`
navigator.clipboard.writeText(copyEndpoint)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [existingAgent])
// Combined create + publish action (auto-deploys workflow if needed)
const handlePublishNewAgent = useCallback(async () => {
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
stateTransitionHistory: true,
}
const authentication: AgentAuthentication = {
schemes: authScheme === 'none' ? ['none'] : [authScheme],
}
try {
// Auto-deploy workflow if not deployed
if (!isDeployed && onDeployWorkflow) {
await onDeployWorkflow()
}
// First create the agent
const newAgent = await createAgent.mutateAsync({
workspaceId,
workflowId,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
// Then immediately publish it
await publishAgent.mutateAsync({
agentId: newAgent.id,
workspaceId,
action: 'publish',
})
queryClient.invalidateQueries({
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId],
})
} catch (error) {
logger.error('Failed to publish A2A agent:', error)
}
}, [
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
createAgent,
publishAgent,
queryClient,
isDeployed,
onDeployWorkflow,
])
// Update agent and republish (auto-deploys workflow if needed)
const handleUpdateAndRepublish = useCallback(async () => {
if (!existingAgent) return
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
stateTransitionHistory: true,
}
const authentication: AgentAuthentication = {
schemes: authScheme === 'none' ? ['none'] : [authScheme],
}
try {
// Auto-deploy workflow if not deployed
if (!isDeployed && onDeployWorkflow) {
await onDeployWorkflow()
}
// First update the agent
await updateAgent.mutateAsync({
agentId: existingAgent.id,
name: name.trim(),
description: description.trim() || undefined,
capabilities,
authentication,
skillTags,
})
// Then republish it
await publishAgent.mutateAsync({
agentId: existingAgent.id,
workspaceId,
action: 'publish',
})
queryClient.invalidateQueries({
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId],
})
} catch (error) {
logger.error('Failed to update and republish A2A agent:', error)
}
}, [
existingAgent,
isDeployed,
onDeployWorkflow,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
updateAgent,
publishAgent,
queryClient,
])
// Curl preview generation
const baseUrl = getBaseUrl()
const endpoint = existingAgent ? `${baseUrl}/api/a2a/serve/${existingAgent.id}` : null
// Get additional input fields from Start block (excluding reserved fields handled via A2A parts)
const additionalInputFields = useMemo(() => {
const allFields = normalizeInputFormatValue(startBlockInputFormat)
return allFields.filter(
(field): field is InputFormatField & { name: string } =>
!!field.name &&
field.name.toLowerCase() !== 'input' &&
field.name.toLowerCase() !== 'data' &&
field.name.toLowerCase() !== 'files'
)
}, [startBlockInputFormat])
const getExampleInputData = useCallback((): Record<string, unknown> => {
const data: Record<string, unknown> = {}
for (const field of additionalInputFields) {
switch (field.type) {
case 'string':
data[field.name] = 'example'
break
case 'number':
data[field.name] = 42
break
case 'boolean':
data[field.name] = true
break
case 'object':
data[field.name] = { key: 'value' }
break
case 'array':
data[field.name] = [1, 2, 3]
break
default:
data[field.name] = 'example'
}
}
return data
}, [additionalInputFields])
const getJsonRpcPayload = useCallback((): Record<string, unknown> => {
const inputData = getExampleInputData()
const hasAdditionalData = Object.keys(inputData).length > 0
// Build parts array: TextPart for message text, DataPart for additional fields
const parts: Array<Record<string, unknown>> = [{ kind: 'text', text: 'Hello, agent!' }]
if (hasAdditionalData) {
parts.push({ kind: 'data', data: inputData })
}
return {
jsonrpc: '2.0',
id: '1',
method: 'message/send',
params: {
message: {
role: 'user',
parts,
},
},
}
}, [getExampleInputData])
const getCurlCommand = useCallback((): string => {
if (!endpoint) return ''
const payload = getJsonRpcPayload()
const requiresAuth = authScheme !== 'none'
switch (language) {
case 'curl':
return requiresAuth
? `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
: `curl -X POST \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
case 'python':
return requiresAuth
? `import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
print(response.json())`
: `import requests
response = requests.post(
"${endpoint}",
headers={"Content-Type": "application/json"},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
print(response.json())`
case 'javascript':
return requiresAuth
? `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data = await response.json();
console.log(data);`
: `const response = await fetch("${endpoint}", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data = await response.json();
console.log(data);`
case 'typescript':
return requiresAuth
? `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data: Record<string, unknown> = await response.json();
console.log(data);`
: `const response = await fetch("${endpoint}", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data: Record<string, unknown> = await response.json();
console.log(data);`
default:
return ''
}
}, [endpoint, language, getJsonRpcPayload, authScheme])
const handleCopyCommand = useCallback(() => {
navigator.clipboard.writeText(getCurlCommand())
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [getCurlCommand])
if (isLoading) {
return (
<div className='-mx-1 space-y-[12px] px-1'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[80px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
<Skeleton className='mt-[6.5px] h-[14px] w-[200px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[70px]' />
<Skeleton className='h-[80px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[90px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
</div>
)
}
return (
<form
id='a2a-deploy-form'
onSubmit={(e) => {
e.preventDefault()
handleCreateOrUpdate()
}}
className='-mx-1 space-y-[12px] overflow-y-auto px-1'
>
{/* Agent Name */}
<div>
<Label
htmlFor='a2a-name'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Agent name <span className='text-red-500'>*</span>
</Label>
<Input
id='a2a-name'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Enter agent name'
required
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Human-readable name shown in the Agent Card
</p>
</div>
{/* Description */}
<div>
<Label
htmlFor='a2a-description'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Description <span className='text-red-500'>*</span>
</Label>
<Textarea
id='a2a-description'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder='Describe what this agent does...'
className='min-h-[80px] resize-none'
required
/>
</div>
{/* Authentication */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Authentication
</Label>
<Combobox
options={authSchemeOptions}
value={authScheme}
onChange={(v) => setAuthScheme(v as AuthScheme)}
placeholder='Select authentication...'
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authScheme === 'none'
? 'Anyone can call this agent without authentication'
: 'Requires X-API-Key header or API key query parameter'}
</p>
</div>
{/* Capabilities */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Capabilities
</Label>
<div className='space-y-[8px]'>
<div className='flex items-center gap-[8px]'>
<Checkbox
id='a2a-push'
checked={pushNotificationsEnabled}
onCheckedChange={(checked) => setPushNotificationsEnabled(checked === true)}
/>
<label htmlFor='a2a-push' className='text-[13px] text-[var(--text-primary)]'>
Push notifications (webhooks)
</label>
</div>
</div>
</div>
{/* Tags */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Tags
</Label>
<TagInput
items={skillTags.map((tag) => ({ value: tag, isValid: true }))}
onAdd={(value) => {
if (!skillTags.includes(value)) {
setSkillTags((prev) => [...prev, value])
return true
}
return false
}}
onRemove={(_value, index) => {
setSkillTags((prev) => prev.filter((_, i) => i !== index))
}}
placeholder='Add tags'
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
/>
</div>
{/* Curl Preview (shown when agent exists) */}
{existingAgent && endpoint && (
<>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Language
</Label>
</div>
<ButtonGroup value={language} onValueChange={(val) => setLanguage(val as CodeLanguage)}>
{(Object.keys(LANGUAGE_LABELS) as CodeLanguage[]).map((lang) => (
<ButtonGroupItem key={lang} value={lang}>
{LANGUAGE_LABELS[lang]}
</ButtonGroupItem>
))}
</ButtonGroup>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Send message (JSON-RPC)
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleCopyCommand}
aria-label='Copy command'
className='!p-1.5 -my-1.5'
>
{copied ? <Check className='h-3 w-3' /> : <Clipboard className='h-3 w-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Code.Viewer
code={getCurlCommand()}
language={LANGUAGE_SYNTAX[language]}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
<div className='mt-[6.5px] flex items-start justify-between gap-2'>
<p className='text-[11px] text-[var(--text-secondary)]'>
External A2A clients can discover and call your agent. TextPart {' '}
<code className='text-[10px]'>&lt;start.input&gt;</code>, DataPart {' '}
<code className='text-[10px]'>&lt;start.data&gt;</code>, FilePart {' '}
<code className='text-[10px]'>&lt;start.files&gt;</code>.
</p>
{missingFields.any && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
)}
</div>
</div>
</>
)}
{/* Hidden triggers for modal footer */}
<button type='submit' data-a2a-save-trigger className='hidden' />
<button type='button' data-a2a-publish-trigger className='hidden' onClick={handlePublish} />
<button
type='button'
data-a2a-unpublish-trigger
className='hidden'
onClick={handleUnpublish}
/>
<button type='button' data-a2a-delete-trigger className='hidden' onClick={handleDelete} />
<button
type='button'
data-a2a-publish-new-trigger
className='hidden'
onClick={handlePublishNewAgent}
/>
<button
type='button'
data-a2a-update-republish-trigger
className='hidden'
onClick={handleUpdateAndRepublish}
/>
</form>
)
}

View File

@@ -513,25 +513,31 @@ export function McpDeploy({
{inputFormat.map((field) => (
<div
key={field.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>{field.name}</p>
<Badge variant='outline' className='text-[10px]'>
{field.type}
</Badge>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name}
</span>
<Badge size='sm'>{field.type}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input
value={parameterDescriptions[field.name] || ''}
onChange={(e) =>
setParameterDescriptions((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
placeholder={`Enter description for ${field.name}`}
/>
</div>
</div>
<Input
value={parameterDescriptions[field.name] || ''}
onChange={(e) =>
setParameterDescriptions((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
placeholder='Description'
className='mt-[6px] h-[28px] text-[12px]'
/>
</div>
))}
</div>
@@ -551,7 +557,6 @@ export function McpDeploy({
searchable
searchPlaceholder='Search servers...'
disabled={!toolName.trim() || isPending}
isLoading={isPending}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{selectedServersLabel}</span>
}

View File

@@ -12,9 +12,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
TagInput,
Textarea,
} from '@/components/emcn'
import { Skeleton, TagInput } from '@/components/ui'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
@@ -404,10 +405,24 @@ export function TemplateDeploy({
Tags
</Label>
<TagInput
value={formData.tags}
onChange={(tags) => updateField('tags', tags)}
items={formData.tags.map((tag) => ({ value: tag, isValid: true }))}
onAdd={(value) => {
if (!formData.tags.includes(value) && formData.tags.length < 10) {
updateField('tags', [...formData.tags, value])
return true
}
return false
}}
onRemove={(_value, index) => {
updateField(
'tags',
formData.tags.filter((_, i) => i !== index)
)
}}
placeholder='Dev, Agents, Research, etc.'
maxTags={10}
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
disabled={isSubmitting}
/>
</div>

View File

@@ -27,6 +27,7 @@ import { useSettingsModalStore } from '@/stores/modals/settings/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { A2aDeploy } from './components/a2a/a2a'
import { ApiDeploy } from './components/api/api'
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { GeneralDeploy } from './components/general/general'
@@ -55,7 +56,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form'
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
export function DeployModal({
open,
@@ -96,6 +97,11 @@ export function DeployModal({
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [hasMcpServers, setHasMcpServers] = useState(false)
const [a2aSubmitting, setA2aSubmitting] = useState(false)
const [a2aCanSave, setA2aCanSave] = useState(false)
const [hasA2aAgent, setHasA2aAgent] = useState(false)
const [isA2aPublished, setIsA2aPublished] = useState(false)
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
const [templateStatus, setTemplateStatus] = useState<{
status: 'pending' | 'approved' | 'rejected' | null
@@ -368,7 +374,6 @@ export function DeployModal({
async (version: number) => {
if (!workflowId) return
// Optimistically update versions to show the new active version immediately
const previousVersions = [...versions]
setVersions((prev) =>
prev.map((v) => ({
@@ -402,7 +407,6 @@ export function DeployModal({
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
// Refresh deployed state in background (no loading flash)
refetchDeployedState()
fetchVersions()
@@ -423,7 +427,6 @@ export function DeployModal({
})
}
} catch (error) {
// Rollback optimistic update on error
setVersions(previousVersions)
throw error
}
@@ -578,6 +581,41 @@ export function DeployModal({
form?.requestSubmit()
}, [])
const handleA2aFormSubmit = useCallback(() => {
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
const handleA2aPublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
publishTrigger?.click()
}, [])
const handleA2aUnpublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const unpublishTrigger = form?.querySelector(
'[data-a2a-unpublish-trigger]'
) as HTMLButtonElement
unpublishTrigger?.click()
}, [])
const handleA2aPublishNew = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const publishNewTrigger = form?.querySelector(
'[data-a2a-publish-new-trigger]'
) as HTMLButtonElement
publishNewTrigger?.click()
}, [])
const handleA2aUpdateRepublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const updateRepublishTrigger = form?.querySelector(
'[data-a2a-update-republish-trigger]'
) as HTMLButtonElement
updateRepublishTrigger?.click()
}, [])
const handleTemplateDelete = useCallback(() => {
const form = document.getElementById('template-deploy-form')
const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
@@ -610,6 +648,7 @@ export function DeployModal({
<ModalTabsTrigger value='general'>General</ModalTabsTrigger>
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
<ModalTabsTrigger value='a2a'>A2A</ModalTabsTrigger>
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
{/* <ModalTabsTrigger value='form'>Form</ModalTabsTrigger> */}
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
@@ -700,6 +739,24 @@ export function DeployModal({
/>
)}
</ModalTabsContent>
<ModalTabsContent value='a2a' className='h-full'>
{workflowId && (
<A2aDeploy
workflowId={workflowId}
workflowName={workflowMetadata?.name || 'Workflow'}
workflowDescription={workflowMetadata?.description}
isDeployed={isDeployed}
workflowNeedsRedeployment={needsRedeployment}
onSubmittingChange={setA2aSubmitting}
onCanSaveChange={setA2aCanSave}
onAgentExistsChange={setHasA2aAgent}
onPublishedChange={setIsA2aPublished}
onNeedsRepublishChange={setA2aNeedsRepublish}
onDeployWorkflow={onDeploy}
/>
)}
</ModalTabsContent>
</ModalBody>
</ModalTabs>
@@ -715,19 +772,23 @@ export function DeployModal({
/>
)}
{activeTab === 'api' && (
<ModalFooter className='items-center justify-end'>
<Button
variant='tertiary'
onClick={() => setIsCreateKeyModalOpen(true)}
disabled={createButtonDisabled}
>
Generate API Key
</Button>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button
variant='tertiary'
onClick={() => setIsCreateKeyModalOpen(true)}
disabled={createButtonDisabled}
>
Generate API Key
</Button>
</div>
</ModalFooter>
)}
{activeTab === 'chat' && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
{chatExists && (
<Button
type='button'
@@ -760,8 +821,9 @@ export function DeployModal({
</ModalFooter>
)}
{activeTab === 'mcp' && isDeployed && hasMcpServers && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button
type='button'
variant='default'
@@ -781,17 +843,17 @@ export function DeployModal({
</ModalFooter>
)}
{activeTab === 'template' && (
<ModalFooter
className={`items-center ${hasExistingTemplate && templateStatus ? 'justify-between' : ''}`}
>
{hasExistingTemplate && templateStatus && (
<ModalFooter className='items-center justify-between'>
{hasExistingTemplate && templateStatus ? (
<TemplateStatusBadge
status={templateStatus.status}
views={templateStatus.views}
stars={templateStatus.stars}
/>
) : (
<div />
)}
<div className='flex gap-2'>
<div className='flex items-center gap-2'>
{hasExistingTemplate && (
<Button
type='button'
@@ -820,8 +882,9 @@ export function DeployModal({
</ModalFooter>
)}
{/* {activeTab === 'form' && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
{formExists && (
<Button
type='button'
@@ -853,6 +916,71 @@ export function DeployModal({
</div>
</ModalFooter>
)} */}
{activeTab === 'a2a' && (
<ModalFooter className='items-center justify-between'>
{/* Status badge on left */}
{hasA2aAgent ? (
isA2aPublished ? (
<Badge variant={a2aNeedsRepublish ? 'amber' : 'green'} size='lg' dot>
{a2aNeedsRepublish ? 'Update deployment' : 'Live'}
</Badge>
) : (
<Badge variant='red' size='lg' dot>
Unpublished
</Badge>
)
) : (
<div />
)}
<div className='flex items-center gap-2'>
{/* No agent exists: Show "Publish Agent" button */}
{!hasA2aAgent && (
<Button
type='button'
variant='tertiary'
onClick={handleA2aPublishNew}
disabled={a2aSubmitting || !a2aCanSave}
>
{a2aSubmitting ? 'Publishing...' : 'Publish Agent'}
</Button>
)}
{/* Agent exists and published: Show Unpublish and Update */}
{hasA2aAgent && isA2aPublished && (
<>
<Button
type='button'
variant='default'
onClick={handleA2aUnpublish}
disabled={a2aSubmitting}
>
Unpublish
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleA2aUpdateRepublish}
disabled={a2aSubmitting || !a2aCanSave || !a2aNeedsRepublish}
>
{a2aSubmitting ? 'Updating...' : 'Update'}
</Button>
</>
)}
{/* Agent exists but unpublished: Show Publish only */}
{hasA2aAgent && !isA2aPublished && (
<Button
type='button'
variant='tertiary'
onClick={handleA2aPublish}
disabled={a2aSubmitting || !a2aCanSave}
>
{a2aSubmitting ? 'Publishing...' : 'Publish'}
</Button>
)}
</div>
</ModalFooter>
)}
</ModalContent>
</Modal>
@@ -952,10 +1080,13 @@ function GeneralFooter({
}: GeneralFooterProps) {
if (!isDeployed) {
return (
<ModalFooter>
<Button variant='tertiary' onClick={onDeploy} disabled={isSubmitting}>
{isSubmitting ? 'Deploying...' : 'Deploy'}
</Button>
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button variant='tertiary' onClick={onDeploy} disabled={isSubmitting}>
{isSubmitting ? 'Deploying...' : 'Deploy'}
</Button>
</div>
</ModalFooter>
)
}

View File

@@ -50,7 +50,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
!isPending &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[var(--brand-tertiary-2)]',
'ring-[var(--brand-tertiary)]',
!isActive &&
!isPending &&
!isDeletedBlock &&

View File

@@ -0,0 +1,33 @@
import type { TaskState } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { deliverPushNotification } from '@/lib/a2a/push-notifications'
const logger = createLogger('A2APushNotificationDelivery')
export interface A2APushNotificationParams {
taskId: string
state: TaskState
}
export const a2aPushNotificationTask = task({
id: 'a2a-push-notification-delivery',
retry: {
maxAttempts: 5,
minTimeoutInMs: 1000,
maxTimeoutInMs: 60000,
factor: 2,
},
run: async (params: A2APushNotificationParams) => {
logger.info('Delivering A2A push notification', params)
const success = await deliverPushNotification(params.taskId, params.state)
if (!success) {
throw new Error(`Failed to deliver push notification for task ${params.taskId}`)
}
logger.info('A2A push notification delivered successfully', params)
return { success: true, taskId: params.taskId, state: params.state }
},
})

View File

@@ -0,0 +1,338 @@
/**
* A2A Block (v0.3)
*
* Enables interaction with external A2A-compatible agents.
* Supports sending messages, querying tasks, cancelling tasks, discovering agents,
* resubscribing to streams, and managing push notification webhooks.
*/
import { A2AIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
export interface A2AResponse extends ToolResponse {
output: {
/** Response content from the agent */
content?: string
/** Task ID */
taskId?: string
/** Context ID for conversation continuity */
contextId?: string
/** Task state */
state?: string
/** Structured output artifacts */
artifacts?: Array<{
name?: string
description?: string
parts: Array<{ kind: string; text?: string; data?: unknown }>
}>
/** Full message history */
history?: Array<{
role: 'user' | 'agent'
parts: Array<{ kind: string; text?: string }>
}>
/** Whether cancellation was successful (cancel_task) */
cancelled?: boolean
/** Whether task is still running (resubscribe) */
isRunning?: boolean
/** Agent name (get_agent_card) */
name?: string
/** Agent description (get_agent_card) */
description?: string
/** Agent URL (get_agent_card) */
url?: string
/** Agent version (get_agent_card) */
version?: string
/** Agent capabilities (get_agent_card) */
capabilities?: Record<string, boolean>
/** Agent skills (get_agent_card) */
skills?: Array<{ id: string; name: string; description?: string }>
/** Agent authentication schemes (get_agent_card) */
authentication?: { schemes: string[] }
/** Push notification webhook URL */
webhookUrl?: string
/** Push notification token */
token?: string
/** Whether push notification config exists */
exists?: boolean
/** Operation success indicator */
success?: boolean
}
}
export const A2ABlock: BlockConfig<A2AResponse> = {
type: 'a2a',
name: 'A2A',
description: 'Interact with external A2A-compatible agents',
longDescription:
'Use the A2A (Agent-to-Agent) protocol to interact with external AI agents. ' +
'Send messages, query task status, cancel tasks, or discover agent capabilities. ' +
'Compatible with any A2A-compliant agent including LangGraph, Google ADK, and other Sim Studio workflows.',
docsLink: 'https://docs.sim.ai/blocks/a2a',
category: 'tools',
bgColor: '#4151B5',
icon: A2AIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Send Message', id: 'send_message' },
{ label: 'Send Message (Streaming)', id: 'send_message_stream' },
{ label: 'Get Task', id: 'get_task' },
{ label: 'Cancel Task', id: 'cancel_task' },
{ label: 'Get Agent Card', id: 'get_agent_card' },
{ label: 'Resubscribe', id: 'resubscribe' },
{ label: 'Set Push Notification', id: 'set_push_notification' },
{ label: 'Get Push Notification', id: 'get_push_notification' },
{ label: 'Delete Push Notification', id: 'delete_push_notification' },
],
defaultValue: 'send_message',
},
{
id: 'agentUrl',
title: 'Agent URL',
type: 'short-input',
placeholder: 'https://api.example.com/a2a/serve/agent-id',
required: true,
description: 'The A2A endpoint URL',
},
{
id: 'message',
title: 'Message',
type: 'long-input',
placeholder: 'Enter your message to the agent...',
description: 'The message to send to the agent',
condition: { field: 'operation', value: ['send_message', 'send_message_stream'] },
required: { field: 'operation', value: ['send_message', 'send_message_stream'] },
},
{
id: 'taskId',
title: 'Task ID',
type: 'short-input',
placeholder: 'Task ID',
description: 'Task ID to query, cancel, continue, or configure',
condition: {
field: 'operation',
value: [
'send_message',
'send_message_stream',
'get_task',
'cancel_task',
'resubscribe',
'set_push_notification',
'get_push_notification',
'delete_push_notification',
],
},
required: {
field: 'operation',
value: [
'get_task',
'cancel_task',
'resubscribe',
'set_push_notification',
'get_push_notification',
'delete_push_notification',
],
},
},
{
id: 'contextId',
title: 'Context ID',
type: 'short-input',
placeholder: 'Optional - for multi-turn conversations',
description: 'Context ID for conversation continuity across tasks',
condition: { field: 'operation', value: ['send_message', 'send_message_stream'] },
},
{
id: 'historyLength',
title: 'History Length',
type: 'short-input',
placeholder: 'Number of messages to include',
description: 'Number of history messages to include in the response',
condition: { field: 'operation', value: 'get_task' },
},
{
id: 'webhookUrl',
title: 'Webhook URL',
type: 'short-input',
placeholder: 'https://your-app.com/webhook',
description: 'HTTPS webhook URL to receive task update notifications',
condition: { field: 'operation', value: 'set_push_notification' },
required: { field: 'operation', value: 'set_push_notification' },
},
{
id: 'token',
title: 'Webhook Token',
type: 'short-input',
password: true,
placeholder: 'Optional token for webhook validation',
description: 'Token that will be included in webhook requests for validation',
condition: { field: 'operation', value: 'set_push_notification' },
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'API key for the remote agent',
description: 'Authentication key for the A2A agent',
},
],
tools: {
access: [
'a2a_send_message',
'a2a_send_message_stream',
'a2a_get_task',
'a2a_cancel_task',
'a2a_get_agent_card',
'a2a_resubscribe',
'a2a_set_push_notification',
'a2a_get_push_notification',
'a2a_delete_push_notification',
],
config: {
tool: (params: Record<string, unknown>) => {
const operation = params.operation as string
switch (operation) {
case 'send_message_stream':
return 'a2a_send_message_stream'
case 'get_task':
return 'a2a_get_task'
case 'cancel_task':
return 'a2a_cancel_task'
case 'get_agent_card':
return 'a2a_get_agent_card'
case 'resubscribe':
return 'a2a_resubscribe'
case 'set_push_notification':
return 'a2a_set_push_notification'
case 'get_push_notification':
return 'a2a_get_push_notification'
case 'delete_push_notification':
return 'a2a_delete_push_notification'
default:
return 'a2a_send_message'
}
},
},
},
inputs: {
operation: {
type: 'string',
description: 'A2A operation to perform',
},
agentUrl: {
type: 'string',
description: 'A2A endpoint URL',
},
message: {
type: 'string',
description: 'Message to send to the agent',
},
taskId: {
type: 'string',
description: 'Task ID to query, cancel, continue, or configure',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',
},
webhookUrl: {
type: 'string',
description: 'HTTPS webhook URL for push notifications',
},
token: {
type: 'string',
description: 'Token for webhook validation',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
outputs: {
content: {
type: 'string',
description: 'The text response from the agent',
},
taskId: {
type: 'string',
description: 'Task ID for follow-up interactions',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
state: {
type: 'string',
description: 'Task state (completed, failed, etc.)',
},
artifacts: {
type: 'array',
description: 'Structured output artifacts from the agent',
},
history: {
type: 'array',
description: 'Full message history of the conversation',
},
cancelled: {
type: 'boolean',
description: 'Whether the task was successfully cancelled',
},
isRunning: {
type: 'boolean',
description: 'Whether the task is still running',
},
name: {
type: 'string',
description: 'Agent name',
},
description: {
type: 'string',
description: 'Agent description',
},
url: {
type: 'string',
description: 'Agent endpoint URL',
},
version: {
type: 'string',
description: 'Agent version',
},
capabilities: {
type: 'json',
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
},
skills: {
type: 'array',
description: 'Skills the agent can perform',
},
authentication: {
type: 'json',
description: 'Supported authentication schemes',
},
webhookUrl: {
type: 'string',
description: 'Configured webhook URL',
},
token: {
type: 'string',
description: 'Webhook validation token',
},
exists: {
type: 'boolean',
description: 'Whether push notification config exists',
},
success: {
type: 'boolean',
description: 'Whether the operation was successful',
},
},
}

View File

@@ -1,3 +1,4 @@
import { A2ABlock } from '@/blocks/blocks/a2a'
import { AgentBlock } from '@/blocks/blocks/agent'
import { AhrefsBlock } from '@/blocks/blocks/ahrefs'
import { AirtableBlock } from '@/blocks/blocks/airtable'
@@ -147,6 +148,7 @@ import { SQSBlock } from './blocks/sqs'
// Registry of all available blocks, alphabetically sorted
export const registry: Record<string, BlockConfig> = {
a2a: A2ABlock,
agent: AgentBlock,
ahrefs: AhrefsBlock,
airtable: AirtableBlock,

View File

@@ -59,6 +59,8 @@ export type ComboboxOption = {
export type ComboboxOptionGroup = {
/** Optional section header label */
section?: string
/** Optional custom section header element (overrides section label) */
sectionElement?: ReactNode
/** Options in this group */
items: ComboboxOption[]
}
@@ -625,11 +627,13 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
<div className='space-y-[2px]'>
{filteredGroups.map((group, groupIndex) => (
<div key={group.section || `group-${groupIndex}`}>
{group.section && (
<div className='px-[6px] py-[4px] font-base text-[11px] text-[var(--text-tertiary)] first:pt-[4px]'>
{group.section}
</div>
)}
{group.sectionElement
? group.sectionElement
: group.section && (
<div className='px-[6px] py-[4px] font-base text-[11px] text-[var(--text-tertiary)] first:pt-[4px]'>
{group.section}
</div>
)}
{group.items.map((option) => {
const isSelected = multiSelect
? multiSelectValues?.includes(option.value)

View File

@@ -53,6 +53,8 @@ const tagVariants = cva(
variants: {
variant: {
default: 'bg-[#bfdbfe] text-[#1d4ed8] dark:bg-[rgba(59,130,246,0.2)] dark:text-[#93c5fd]',
secondary:
'border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]',
invalid:
'bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]',
},
@@ -102,7 +104,9 @@ const Tag = React.memo(function Tag({
'flex-shrink-0 opacity-80 transition-opacity hover:opacity-100 focus:outline-none',
variant === 'invalid'
? 'text-[var(--text-error)]'
: 'text-[#1d4ed8] dark:text-[#93c5fd]'
: variant === 'secondary'
? 'text-[var(--text-tertiary)]'
: 'text-[#1d4ed8] dark:text-[#93c5fd]'
)}
aria-label={`Remove ${value}`}
>
@@ -192,6 +196,8 @@ export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
renderTagSuffix?: (value: string, index: number) => React.ReactNode
/** Options for enabling file input (drag/drop and file picker) */
fileInputOptions?: FileInputOptions
/** Variant for valid tags (defaults to 'default') */
tagVariant?: 'default' | 'secondary'
}
/**
@@ -222,6 +228,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
triggerKeys = ['Enter', ',', ' '],
renderTagSuffix,
fileInputOptions,
tagVariant = 'default',
variant,
},
ref
@@ -399,7 +406,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<Tag
key={`item-${index}`}
value={item.value}
variant={item.isValid ? 'default' : 'invalid'}
variant={item.isValid ? tagVariant : 'invalid'}
onRemove={() => onRemove(item.value, index, item.isValid)}
disabled={disabled}
suffix={item.isValid ? renderTagSuffix?.(item.value, index) : undefined}
@@ -409,7 +416,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
className={cn(
'flex items-center',
inputValue.trim() &&
cn(tagVariants({ variant: 'default' }), 'gap-0 py-0 pr-0 pl-[4px] opacity-80')
cn(tagVariants({ variant: tagVariant }), 'gap-0 py-0 pr-0 pl-[4px] opacity-80')
)}
>
<div className='relative inline-flex'>

View File

@@ -4061,6 +4061,31 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
<circle cx='544' cy='307' r='27' fill='currentColor' />
<circle cx='154' cy='307' r='27' fill='currentColor' />
<circle cx='706' cy='307' r='27' fill='currentColor' />
<circle cx='316' cy='307' r='27' fill='currentColor' />
<path
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
stroke='currentColor'
strokeWidth='48'
strokeLinecap='round'
/>
<path
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
fill='currentColor'
/>
<path
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
fill='currentColor'
/>
</svg>
)
}
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>

View File

@@ -0,0 +1,309 @@
/**
* A2A Agents React Query Hooks
*
* Hooks for managing A2A agents in the UI.
*/
import type { AgentCapabilities, AgentSkill } from '@a2a-js/sdk'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { AgentAuthentication } from '@/lib/a2a/types'
/**
* A2A Agent as returned from the API
*/
export interface A2AAgent {
id: string
workspaceId: string
workflowId: string
name: string
description?: string
version: string
capabilities: AgentCapabilities
skills: AgentSkill[]
authentication: AgentAuthentication
isPublished: boolean
publishedAt?: string
createdAt: string
updatedAt: string
workflowName?: string
workflowDescription?: string
isDeployed?: boolean
taskCount?: number
}
/**
* Query keys for A2A agents
*/
export const a2aAgentKeys = {
all: ['a2a-agents'] as const,
list: (workspaceId: string) => [...a2aAgentKeys.all, 'list', workspaceId] as const,
detail: (agentId: string) => [...a2aAgentKeys.all, 'detail', agentId] as const,
}
/**
* Fetch A2A agents for a workspace
*/
async function fetchA2AAgents(workspaceId: string): Promise<A2AAgent[]> {
const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`)
if (!response.ok) {
throw new Error('Failed to fetch A2A agents')
}
const data = await response.json()
return data.agents
}
/**
* Hook to list A2A agents for a workspace
*/
export function useA2AAgents(workspaceId: string) {
return useQuery({
queryKey: a2aAgentKeys.list(workspaceId),
queryFn: () => fetchA2AAgents(workspaceId),
enabled: Boolean(workspaceId),
staleTime: 60 * 1000, // 1 minute
})
}
/**
* Agent Card as returned from the agent detail endpoint
*/
export interface A2AAgentCard {
name: string
description?: string
url: string
version: string
documentationUrl?: string
provider?: {
organization: string
url?: string
}
capabilities: AgentCapabilities
skills: AgentSkill[]
authentication?: AgentAuthentication
defaultInputModes?: string[]
defaultOutputModes?: string[]
}
/**
* Fetch a single A2A agent card (discovery document)
*/
async function fetchA2AAgentCard(agentId: string): Promise<A2AAgentCard> {
const response = await fetch(`/api/a2a/agents/${agentId}`)
if (!response.ok) {
throw new Error('Failed to fetch A2A agent')
}
return response.json()
}
/**
* Hook to get a single A2A agent card (discovery document)
*/
export function useA2AAgentCard(agentId: string) {
return useQuery({
queryKey: a2aAgentKeys.detail(agentId),
queryFn: () => fetchA2AAgentCard(agentId),
enabled: Boolean(agentId),
})
}
/**
* Create A2A agent params
*/
export interface CreateA2AAgentParams {
workspaceId: string
workflowId: string
name?: string
description?: string
capabilities?: AgentCapabilities
authentication?: AgentAuthentication
skillTags?: string[]
}
/**
* Create a new A2A agent
*/
async function createA2AAgent(params: CreateA2AAgentParams): Promise<A2AAgent> {
const response = await fetch('/api/a2a/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create A2A agent')
}
const data = await response.json()
return data.agent
}
/**
* Hook to create an A2A agent
*/
export function useCreateA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createA2AAgent,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(data.workspaceId),
})
},
})
}
/**
* Update A2A agent params
*/
export interface UpdateA2AAgentParams {
agentId: string
name?: string
description?: string
version?: string
capabilities?: AgentCapabilities
skills?: AgentSkill[]
authentication?: AgentAuthentication
isPublished?: boolean
skillTags?: string[]
}
/**
* Update an A2A agent
*/
async function updateA2AAgent(params: UpdateA2AAgentParams): Promise<A2AAgent> {
const { agentId, ...body } = params
const response = await fetch(`/api/a2a/agents/${agentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update A2A agent')
}
const data = await response.json()
return data.agent
}
/**
* Hook to update an A2A agent
*/
export function useUpdateA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateA2AAgent,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.detail(data.id),
})
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(data.workspaceId),
})
},
})
}
/**
* Delete an A2A agent
*/
async function deleteA2AAgent(params: { agentId: string; workspaceId: string }): Promise<void> {
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete A2A agent')
}
}
/**
* Hook to delete an A2A agent
*/
export function useDeleteA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteA2AAgent,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(variables.workspaceId),
})
},
})
}
/**
* Publish/unpublish agent params
*/
export interface PublishA2AAgentParams {
agentId: string
workspaceId: string
action: 'publish' | 'unpublish' | 'refresh'
}
/**
* Publish or unpublish an A2A agent
*/
async function publishA2AAgent(params: PublishA2AAgentParams): Promise<{
isPublished?: boolean
skills?: AgentSkill[]
}> {
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: params.action }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update A2A agent')
}
return response.json()
}
/**
* Hook to publish/unpublish an A2A agent
*/
export function usePublishA2AAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: publishA2AAgent,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.detail(variables.agentId),
})
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.list(variables.workspaceId),
})
},
})
}
/**
* Fetch A2A agent by workflow ID
*/
async function fetchA2AAgentByWorkflow(
workspaceId: string,
workflowId: string
): Promise<A2AAgent | null> {
const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`)
if (!response.ok) {
throw new Error('Failed to fetch A2A agents')
}
const data = await response.json()
const agents = data.agents as A2AAgent[]
return agents.find((agent) => agent.workflowId === workflowId) || null
}
/**
* Hook to get A2A agent by workflow ID
*/
export function useA2AAgentByWorkflow(workspaceId: string, workflowId: string) {
return useQuery({
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId] as const,
queryFn: () => fetchA2AAgentByWorkflow(workspaceId, workflowId),
enabled: Boolean(workspaceId) && Boolean(workflowId),
staleTime: 30 * 1000, // 30 seconds
})
}

View File

@@ -0,0 +1,262 @@
/**
* A2A Tasks React Query Hooks (v0.3)
*
* Hooks for interacting with A2A tasks in the UI.
*/
import type { Artifact, Message, TaskState } from '@a2a-js/sdk'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isTerminalState } from '@/lib/a2a/utils'
/** A2A v0.3 JSON-RPC method names */
const A2A_METHODS = {
MESSAGE_SEND: 'message/send',
TASKS_GET: 'tasks/get',
TASKS_CANCEL: 'tasks/cancel',
} as const
/**
* A2A Task as returned from queries
*/
export interface A2ATask {
kind: 'task'
id: string
contextId?: string
status: {
state: TaskState
timestamp?: string
message?: string
}
history?: Message[]
artifacts?: Artifact[]
metadata?: Record<string, unknown>
}
/**
* Query keys for A2A tasks
*/
export const a2aTaskKeys = {
all: ['a2a-tasks'] as const,
detail: (agentUrl: string, taskId: string) =>
[...a2aTaskKeys.all, 'detail', agentUrl, taskId] as const,
}
/**
* Send task params
*/
export interface SendA2ATaskParams {
agentUrl: string
message: string
taskId?: string
contextId?: string
apiKey?: string
}
/**
* Send task response
*/
export interface SendA2ATaskResponse {
content: string
taskId: string
contextId?: string
state: TaskState
artifacts?: Artifact[]
history?: Message[]
}
/**
* Send a message to an A2A agent (v0.3)
*/
async function sendA2ATask(params: SendA2ATaskParams): Promise<SendA2ATaskResponse> {
const userMessage: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text: params.message }],
...(params.taskId && { taskId: params.taskId }),
...(params.contextId && { contextId: params.contextId }),
}
const response = await fetch(params.agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(params.apiKey ? { Authorization: `Bearer ${params.apiKey}` } : {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method: A2A_METHODS.MESSAGE_SEND,
params: {
message: userMessage,
},
}),
})
if (!response.ok) {
throw new Error(`A2A request failed: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.error) {
throw new Error(result.error.message || 'A2A request failed')
}
const task = result.result as A2ATask
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
const content = lastAgentMessage
? lastAgentMessage.parts
.filter((p): p is import('@a2a-js/sdk').TextPart => p.kind === 'text')
.map((p) => p.text)
.join('')
: ''
return {
content,
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
}
}
/**
* Hook to send a message to an A2A agent
*/
export function useSendA2ATask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: sendA2ATask,
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: a2aTaskKeys.detail(variables.agentUrl, data.taskId),
})
},
})
}
/**
* Get task params
*/
export interface GetA2ATaskParams {
agentUrl: string
taskId: string
apiKey?: string
historyLength?: number
}
/**
* Fetch a task from an A2A agent
*/
async function fetchA2ATask(params: GetA2ATaskParams): Promise<A2ATask> {
const response = await fetch(params.agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(params.apiKey ? { Authorization: `Bearer ${params.apiKey}` } : {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method: A2A_METHODS.TASKS_GET,
params: {
id: params.taskId,
historyLength: params.historyLength,
},
}),
})
if (!response.ok) {
throw new Error(`A2A request failed: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.error) {
throw new Error(result.error.message || 'A2A request failed')
}
return result.result as A2ATask
}
/**
* Hook to get an A2A task
*/
export function useA2ATask(params: GetA2ATaskParams | null) {
return useQuery({
queryKey: params ? a2aTaskKeys.detail(params.agentUrl, params.taskId) : ['disabled'],
queryFn: () => fetchA2ATask(params!),
enabled: Boolean(params?.agentUrl && params?.taskId),
staleTime: 5 * 1000, // 5 seconds - tasks can change quickly
refetchInterval: (query) => {
// Auto-refresh if task is still running
const data = query.state.data as A2ATask | undefined
if (data && !isTerminalState(data.status.state)) {
return 2000 // 2 seconds
}
return false
},
})
}
/**
* Cancel task params
*/
export interface CancelA2ATaskParams {
agentUrl: string
taskId: string
apiKey?: string
}
/**
* Cancel a task
*/
async function cancelA2ATask(params: CancelA2ATaskParams): Promise<A2ATask> {
const response = await fetch(params.agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(params.apiKey ? { Authorization: `Bearer ${params.apiKey}` } : {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method: A2A_METHODS.TASKS_CANCEL,
params: {
id: params.taskId,
},
}),
})
if (!response.ok) {
throw new Error(`A2A request failed: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.error) {
throw new Error(result.error.message || 'A2A request failed')
}
return result.result as A2ATask
}
/**
* Hook to cancel an A2A task
*/
export function useCancelA2ATask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: cancelA2ATask,
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: a2aTaskKeys.detail(variables.agentUrl, variables.taskId),
})
},
})
}

View File

@@ -0,0 +1,130 @@
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
A2A_DEFAULT_CAPABILITIES,
A2A_DEFAULT_INPUT_MODES,
A2A_DEFAULT_OUTPUT_MODES,
A2A_PROTOCOL_VERSION,
} from './constants'
import type { AgentCapabilities, AgentSkill } from './types'
import { buildA2AEndpointUrl, sanitizeAgentName } from './utils'
export interface AppAgentCard {
name: string
description: string
url: string
protocolVersion: string
documentationUrl?: string
provider?: {
organization: string
url: string
}
capabilities: AgentCapabilities
skills: AgentSkill[]
defaultInputModes: string[]
defaultOutputModes: string[]
}
interface WorkflowData {
id: string
name: string
description?: string | null
}
interface AgentData {
id: string
name: string
description?: string | null
version: string
capabilities?: AgentCapabilities
skills?: AgentSkill[]
}
export function generateAgentCard(agent: AgentData, workflow: WorkflowData): AppAgentCard {
const baseUrl = getBaseUrl()
const description =
agent.description || workflow.description || `${agent.name} - A2A Agent powered by Sim Studio`
return {
name: agent.name,
description,
url: buildA2AEndpointUrl(baseUrl, agent.id),
protocolVersion: A2A_PROTOCOL_VERSION,
documentationUrl: `${baseUrl}/docs/a2a`,
provider: {
organization: 'Sim Studio',
url: baseUrl,
},
capabilities: {
...A2A_DEFAULT_CAPABILITIES,
...agent.capabilities,
},
skills: agent.skills || [
{
id: 'execute',
name: `Execute ${workflow.name}`,
description: workflow.description || `Execute the ${workflow.name} workflow`,
tags: ['workflow', 'automation'],
},
],
defaultInputModes: [...A2A_DEFAULT_INPUT_MODES],
defaultOutputModes: [...A2A_DEFAULT_OUTPUT_MODES],
}
}
export function generateSkillsFromWorkflow(
workflowName: string,
workflowDescription: string | undefined | null,
tags?: string[]
): AgentSkill[] {
const skill: AgentSkill = {
id: 'execute',
name: `Execute ${workflowName}`,
description: workflowDescription || `Execute the ${workflowName} workflow`,
tags: tags?.length ? tags : ['workflow', 'automation'],
}
return [skill]
}
export function generateDefaultAgentName(workflowName: string): string {
return sanitizeAgentName(workflowName)
}
export function validateAgentCard(card: unknown): card is AppAgentCard {
if (!card || typeof card !== 'object') return false
const c = card as Record<string, unknown>
if (typeof c.name !== 'string' || !c.name) return false
if (typeof c.url !== 'string' || !c.url) return false
if (typeof c.description !== 'string') return false
if (c.capabilities && typeof c.capabilities !== 'object') return false
if (!Array.isArray(c.skills)) return false
return true
}
export function mergeAgentCard(
existing: AppAgentCard,
updates: Partial<AppAgentCard>
): AppAgentCard {
return {
...existing,
...updates,
capabilities: {
...existing.capabilities,
...updates.capabilities,
},
skills: updates.skills || existing.skills,
}
}
export function getAgentCardPaths(agentId: string) {
const baseUrl = getBaseUrl()
return {
card: `${baseUrl}/api/a2a/agents/${agentId}`,
serve: `${baseUrl}/api/a2a/serve/${agentId}`,
}
}

View File

@@ -0,0 +1,23 @@
export { AGENT_CARD_PATH } from '@a2a-js/sdk'
export const A2A_PROTOCOL_VERSION = '0.3.0'
export const A2A_DEFAULT_TIMEOUT = 300000
export const A2A_MAX_HISTORY_LENGTH = 100
export const A2A_DEFAULT_CAPABILITIES = {
streaming: true,
pushNotifications: false,
stateTransitionHistory: true,
} as const
export const A2A_DEFAULT_INPUT_MODES = ['text'] as const
export const A2A_DEFAULT_OUTPUT_MODES = ['text'] as const
export const A2A_CACHE = {
AGENT_CARD_TTL: 3600, // 1 hour
TASK_TTL: 86400, // 24 hours
} as const
export const A2A_TERMINAL_STATES = ['completed', 'failed', 'canceled', 'rejected'] as const

83
apps/sim/lib/a2a/index.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { AppAgentCard } from './agent-card'
import {
generateAgentCard,
generateDefaultAgentName,
generateSkillsFromWorkflow,
getAgentCardPaths,
mergeAgentCard,
validateAgentCard,
} from './agent-card'
import {
A2A_CACHE,
A2A_DEFAULT_CAPABILITIES,
A2A_DEFAULT_INPUT_MODES,
A2A_DEFAULT_OUTPUT_MODES,
A2A_DEFAULT_TIMEOUT,
A2A_MAX_HISTORY_LENGTH,
A2A_PROTOCOL_VERSION,
A2A_TERMINAL_STATES,
} from './constants'
import { deliverPushNotification, notifyTaskStateChange } from './push-notifications'
import type {
A2AAgentConfig,
A2AApiResponse,
A2ATaskRecord,
AgentAuthentication,
AgentCardSignature,
JSONSchema,
} from './types'
import {
buildA2AEndpointUrl,
buildAgentCardUrl,
createA2AToolId,
createAgentMessage,
createTextPart,
createUserMessage,
extractTextContent,
getLastAgentMessage,
getLastAgentMessageText,
isTerminalState,
parseA2AToolId,
sanitizeAgentName,
} from './utils'
export {
generateAgentCard,
generateDefaultAgentName,
generateSkillsFromWorkflow,
getAgentCardPaths,
mergeAgentCard,
validateAgentCard,
A2A_CACHE,
A2A_DEFAULT_CAPABILITIES,
A2A_DEFAULT_INPUT_MODES,
A2A_DEFAULT_OUTPUT_MODES,
A2A_DEFAULT_TIMEOUT,
A2A_MAX_HISTORY_LENGTH,
A2A_PROTOCOL_VERSION,
A2A_TERMINAL_STATES,
deliverPushNotification,
notifyTaskStateChange,
buildA2AEndpointUrl,
buildAgentCardUrl,
createA2AToolId,
createAgentMessage,
createTextPart,
createUserMessage,
extractTextContent,
getLastAgentMessage,
getLastAgentMessageText,
isTerminalState,
parseA2AToolId,
sanitizeAgentName,
}
export type {
AppAgentCard,
A2AAgentConfig,
A2AApiResponse,
A2ATaskRecord,
AgentAuthentication,
AgentCardSignature,
JSONSchema,
}

View File

@@ -0,0 +1,109 @@
import type { Artifact, Message, TaskState } from '@a2a-js/sdk'
import { db } from '@sim/db'
import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('A2APushNotifications')
/**
* Deliver push notification for a task state change.
* Works without any external dependencies (DB-only).
*/
export async function deliverPushNotification(taskId: string, state: TaskState): Promise<boolean> {
const [config] = await db
.select()
.from(a2aPushNotificationConfig)
.where(eq(a2aPushNotificationConfig.taskId, taskId))
.limit(1)
if (!config || !config.isActive) {
return true
}
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, taskId)).limit(1)
if (!task) {
logger.warn('Task not found for push notification', { taskId })
return false
}
const timestamp = new Date().toISOString()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (config.token) {
headers.Authorization = `Bearer ${config.token}`
}
try {
const response = await fetch(config.url, {
method: 'POST',
headers,
body: JSON.stringify({
kind: 'task-update',
task: {
kind: 'task',
id: task.id,
contextId: task.sessionId,
status: { state, timestamp },
history: task.messages as Message[],
artifacts: (task.artifacts as Artifact[]) || [],
},
}),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
logger.error('Push notification delivery failed', {
taskId,
url: config.url,
status: response.status,
})
return false
}
logger.info('Push notification delivered successfully', { taskId, state })
return true
} catch (error) {
logger.error('Push notification delivery error', { taskId, error })
return false
}
}
/**
* Notify task state change.
* Uses trigger.dev for durable delivery when available, falls back to inline delivery.
*/
export async function notifyTaskStateChange(taskId: string, state: TaskState): Promise<void> {
const [config] = await db
.select({ id: a2aPushNotificationConfig.id })
.from(a2aPushNotificationConfig)
.where(eq(a2aPushNotificationConfig.taskId, taskId))
.limit(1)
if (!config) {
return
}
if (isTriggerDevEnabled) {
try {
const { a2aPushNotificationTask } = await import(
'@/background/a2a-push-notification-delivery'
)
await a2aPushNotificationTask.trigger({ taskId, state })
logger.info('Push notification queued to trigger.dev', { taskId, state })
} catch (error) {
logger.warn('Failed to queue push notification, falling back to inline delivery', {
taskId,
error,
})
await deliverPushNotification(taskId, state)
}
} else {
await deliverPushNotification(taskId, state)
}
}

142
apps/sim/lib/a2a/types.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* A2A (Agent-to-Agent) Protocol Types (v0.3)
* @see https://a2a-protocol.org/specification
*/
export {
AGENT_CARD_PATH,
type AgentCapabilities,
type AgentCard,
type AgentProvider,
type AgentSkill,
type Artifact,
type DataPart,
type FilePart,
type Message,
type MessageSendConfiguration,
type MessageSendParams,
type Part,
type PushNotificationConfig,
type Task,
type TaskArtifactUpdateEvent,
type TaskIdParams,
type TaskPushNotificationConfig,
type TaskQueryParams,
type TaskState,
type TaskStatus,
type TaskStatusUpdateEvent,
type TextPart,
} from '@a2a-js/sdk'
export {
type A2AClientOptions,
type AuthenticationHandler,
Client,
type ClientConfig,
ClientFactory,
type RequestOptions,
} from '@a2a-js/sdk/client'
export {
A2AError,
type AgentExecutor,
DefaultExecutionEventBus,
DefaultRequestHandler,
type ExecutionEventBus,
InMemoryTaskStore,
JsonRpcTransportHandler,
type RequestContext,
type TaskStore,
} from '@a2a-js/sdk/server'
/**
* App-specific: Extended MessageSendParams
* Note: Structured inputs should be passed via DataPart in message.parts (A2A spec compliant)
* Files should be passed via FilePart in message.parts
*/
export interface ExtendedMessageSendParams {
message: import('@a2a-js/sdk').Message
configuration?: import('@a2a-js/sdk').MessageSendConfiguration
}
/**
* App-specific: Database model for A2A Agent configuration
*/
export interface A2AAgentConfig {
id: string
workspaceId: string
workflowId: string
name: string
description?: string
version: string
capabilities: import('@a2a-js/sdk').AgentCapabilities
skills: import('@a2a-js/sdk').AgentSkill[]
authentication?: AgentAuthentication
signatures?: AgentCardSignature[]
isPublished: boolean
publishedAt?: Date
createdAt: Date
updatedAt: Date
}
/**
* App-specific: Agent authentication configuration
*/
export interface AgentAuthentication {
schemes: Array<'bearer' | 'apiKey' | 'oauth2' | 'none'>
credentials?: string
}
/**
* App-specific: Agent card signature (v0.3)
*/
export interface AgentCardSignature {
algorithm: string
keyId: string
value: string
}
/**
* App-specific: Database model for A2A Task record
*/
export interface A2ATaskRecord {
id: string
agentId: string
contextId?: string
status: import('@a2a-js/sdk').TaskState
history: import('@a2a-js/sdk').Message[]
artifacts?: import('@a2a-js/sdk').Artifact[]
executionId?: string
metadata?: Record<string, unknown>
createdAt: Date
updatedAt: Date
completedAt?: Date
}
/**
* App-specific: A2A API Response wrapper
*/
export interface A2AApiResponse<T = unknown> {
success: boolean
data?: T
error?: string
}
/**
* App-specific: JSON Schema definition for skill input/output schemas
*/
export interface JSONSchema {
type?: string
properties?: Record<string, JSONSchema>
items?: JSONSchema
required?: string[]
description?: string
enum?: unknown[]
default?: unknown
format?: string
minimum?: number
maximum?: number
minLength?: number
maxLength?: number
pattern?: string
additionalProperties?: boolean | JSONSchema
[key: string]: unknown
}

212
apps/sim/lib/a2a/utils.ts Normal file
View File

@@ -0,0 +1,212 @@
import type { DataPart, FilePart, Message, Part, Task, TaskState, TextPart } from '@a2a-js/sdk'
import { A2A_TERMINAL_STATES } from './constants'
export function isTerminalState(state: TaskState): boolean {
return (A2A_TERMINAL_STATES as readonly string[]).includes(state)
}
export function extractTextContent(message: Message): string {
return message.parts
.filter((part): part is TextPart => part.kind === 'text')
.map((part) => part.text)
.join('\n')
}
export function extractDataContent(message: Message): Record<string, unknown> {
const dataParts = message.parts.filter((part): part is DataPart => part.kind === 'data')
return dataParts.reduce((acc, part) => ({ ...acc, ...part.data }), {})
}
export interface A2AFile {
name?: string
mimeType?: string
uri?: string
bytes?: string
}
export function extractFileContent(message: Message): A2AFile[] {
return message.parts
.filter((part): part is FilePart => part.kind === 'file')
.map((part) => ({
name: part.file.name,
mimeType: part.file.mimeType,
...('uri' in part.file ? { uri: part.file.uri } : {}),
...('bytes' in part.file ? { bytes: part.file.bytes } : {}),
}))
}
export interface ExecutionFileInput {
type: 'file' | 'url'
data: string
name: string
mime?: string
}
/**
* Convert A2A FileParts to execution file format
* This format is then processed by processInputFileFields in the execute endpoint
* FileWithUri → type 'url', FileWithBytes → type 'file' with data URL
* Files without uri or bytes are filtered out as invalid
*/
export function convertFilesToExecutionFormat(files: A2AFile[]): ExecutionFileInput[] {
return files
.filter((file) => file.uri || file.bytes) // Skip invalid files without content
.map((file) => {
if (file.uri) {
return {
type: 'url' as const,
data: file.uri,
name: file.name || 'file',
mime: file.mimeType,
}
}
const dataUrl = `data:${file.mimeType || 'application/octet-stream'};base64,${file.bytes}`
return {
type: 'file' as const,
data: dataUrl,
name: file.name || 'file',
mime: file.mimeType,
}
})
}
export interface WorkflowInput {
input: string
data?: Record<string, unknown>
files?: ExecutionFileInput[]
}
export function extractWorkflowInput(message: Message): WorkflowInput | null {
const messageText = extractTextContent(message)
const dataContent = extractDataContent(message)
const fileContent = extractFileContent(message)
const files = convertFilesToExecutionFormat(fileContent)
const hasData = Object.keys(dataContent).length > 0
if (!messageText && !hasData && files.length === 0) {
return null
}
return {
input: messageText,
...(hasData ? { data: dataContent } : {}),
...(files.length > 0 ? { files } : {}),
}
}
export function createTextPart(text: string): Part {
return { kind: 'text', text }
}
export function createUserMessage(text: string): Message {
return {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text }],
}
}
export function createAgentMessage(text: string): Message {
return {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'agent',
parts: [{ kind: 'text', text }],
}
}
export function createA2AToolId(agentId: string, skillId: string): string {
return `a2a:${agentId}:${skillId}`
}
export function parseA2AToolId(toolId: string): { agentId: string; skillId: string } | null {
const parts = toolId.split(':')
if (parts.length !== 3 || parts[0] !== 'a2a') {
return null
}
return { agentId: parts[1], skillId: parts[2] }
}
export function sanitizeAgentName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 64)
}
export function buildA2AEndpointUrl(baseUrl: string, agentId: string): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/a2a/serve/${agentId}`
}
export function buildAgentCardUrl(baseUrl: string, agentId: string): string {
const base = baseUrl.replace(/\/$/, '')
return `${base}/api/a2a/agents/${agentId}`
}
export function getLastAgentMessage(task: Task): Message | undefined {
return task.history?.filter((m) => m.role === 'agent').pop()
}
export function getLastAgentMessageText(task: Task): string {
const message = getLastAgentMessage(task)
return message ? extractTextContent(message) : ''
}
export interface ParsedSSEChunk {
/** Incremental content from chunk events */
content: string
/** Final content if this chunk contains the final event */
finalContent?: string
/** Whether this chunk indicates the stream is done */
isDone: boolean
}
/**
* Parse workflow SSE chunk and extract clean content
*
* Workflow execute endpoint returns SSE in this format:
* - data: {"event":"chunk","data":{"content":"partial text"}}
* - data: {"event":"final","data":{"success":true,"output":{"content":"full text"}}}
* - data: "[DONE]"
*
* This function extracts the actual text content for A2A streaming
*/
export function parseWorkflowSSEChunk(chunk: string): ParsedSSEChunk {
const result: ParsedSSEChunk = {
content: '',
isDone: false,
}
const lines = chunk.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const dataContent = trimmed.slice(5).trim()
if (dataContent === '"[DONE]"' || dataContent === '[DONE]') {
result.isDone = true
continue
}
try {
const parsed = JSON.parse(dataContent)
if (parsed.event === 'chunk' && parsed.data?.content) {
result.content += parsed.data.content
} else if (parsed.event === 'final' && parsed.data?.output?.content) {
result.finalContent = parsed.data.output.content
result.isDone = true
}
} catch {
// Not valid JSON, skip
}
}
return result
}

View File

@@ -113,7 +113,7 @@ export async function checkHybridAuth(
}
}
// 3. Try API key auth
// 3. Try API key auth (X-API-Key header only)
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
const result = await authenticateApiKeyFromHeader(apiKeyHeader)

View File

@@ -99,7 +99,6 @@ export interface SendMessageRequest {
workflowId?: string
executionId?: string
}>
commands?: string[]
}
/**

View File

@@ -1,54 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class CrawlWebsiteClientTool extends BaseClientTool {
static readonly id = 'crawl_website'
constructor(toolCallId: string) {
super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Crawled website', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
switch (state) {
case ClientToolCallState.success:
return `Crawled ${truncated}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Crawling ${truncated}`
case ClientToolCallState.error:
return `Failed to crawl ${truncated}`
case ClientToolCallState.aborted:
return `Aborted crawling ${truncated}`
case ClientToolCallState.rejected:
return `Skipped crawling ${truncated}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,55 +0,0 @@
import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetPageContentsClientTool extends BaseClientTool {
static readonly id = 'get_page_contents'
constructor(toolCallId: string) {
super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) {
const firstUrl = String(params.urls[0])
const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl
const count = params.urls.length
switch (state) {
case ClientToolCallState.success:
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}`
case ClientToolCallState.error:
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}`
case ClientToolCallState.aborted:
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}`
case ClientToolCallState.rejected:
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,54 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class ScrapePageClientTool extends BaseClientTool {
static readonly id = 'scrape_page'
constructor(toolCallId: string) {
super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Scraped page', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
switch (state) {
case ClientToolCallState.success:
return `Scraped ${truncated}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Scraping ${truncated}`
case ClientToolCallState.error:
return `Failed to scrape ${truncated}`
case ClientToolCallState.aborted:
return `Aborted scraping ${truncated}`
case ClientToolCallState.rejected:
return `Skipped scraping ${truncated}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,50 +0,0 @@
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchLibraryDocsClientTool extends BaseClientTool {
static readonly id = 'search_library_docs'
constructor(toolCallId: string) {
super(toolCallId, SearchLibraryDocsClientTool.id, SearchLibraryDocsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Read docs', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to read docs', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted reading docs', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped reading docs', icon: MinusCircle },
},
getDynamicText: (params, state) => {
const libraryName = params?.library_name
if (libraryName && typeof libraryName === 'string') {
switch (state) {
case ClientToolCallState.success:
return `Read ${libraryName} docs`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Reading ${libraryName} docs`
case ClientToolCallState.error:
return `Failed to read ${libraryName} docs`
case ClientToolCallState.aborted:
return `Aborted reading ${libraryName} docs`
case ClientToolCallState.rejected:
return `Skipped reading ${libraryName} docs`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,9 +1,19 @@
import { createLogger } from '@sim/logger'
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface SearchOnlineArgs {
query: string
num?: number
type?: string
gl?: string
hl?: string
}
export class SearchOnlineClientTool extends BaseClientTool {
static readonly id = 'search_online'
@@ -22,7 +32,6 @@ export class SearchOnlineClientTool extends BaseClientTool {
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
@@ -47,7 +56,28 @@ export class SearchOnlineClientTool extends BaseClientTool {
},
}
async execute(): Promise<void> {
return
async execute(args?: SearchOnlineArgs): Promise<void> {
const logger = createLogger('SearchOnlineClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'search_online', payload: args || {} }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Online search complete', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Search failed')
}
}
}

View File

@@ -6,6 +6,47 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DeploymentUtils')
export interface InputField {
name: string
type: string
}
/**
* Gets the input format from the Start block
* Returns an array of field definitions with name and type
*/
export function getStartBlockInputFormat(): InputField[] {
try {
const candidates = resolveStartCandidates(useWorkflowStore.getState().blocks, {
execution: 'api',
})
const targetCandidate =
candidates.find((candidate) => candidate.path === StartBlockPath.UNIFIED) ||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_API) ||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_INPUT) ||
candidates.find((candidate) => candidate.path === StartBlockPath.LEGACY_STARTER)
const targetBlock = targetCandidate?.block
if (targetBlock) {
const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat')
if (inputFormat && Array.isArray(inputFormat)) {
return inputFormat
.map((field: { name?: string; type?: string }) => ({
name: field.name || '',
type: field.type || 'string',
}))
.filter((field) => field.name)
}
}
} catch (error) {
logger.warn('Error getting start block input format:', error)
}
return []
}
/**
* Gets the input format example for a workflow's API deployment
* Returns the -d flag with example data if inputs exist, empty string otherwise
@@ -72,13 +113,11 @@ export function getInputFormatExample(
})
}
// Add streaming parameters if enabled and outputs are selected
if (includeStreaming && selectedStreamingOutputs.length > 0) {
exampleData.stream = true
const convertedOutputs = selectedStreamingOutputs
.map((outputId) => {
// If it starts with a UUID, convert to blockName.attribute format
if (startsWithUuid(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return null
@@ -86,25 +125,20 @@ export function getInputFormatExample(
const blockId = outputId.substring(0, underscoreIndex)
const attribute = outputId.substring(underscoreIndex + 1)
// Find the block by ID and get its name
const block = blocks.find((b) => b.id === blockId)
if (block?.name) {
return `${normalizeName(block.name)}.${attribute}`
}
// Block not found (deleted), return null to filter out
return null
}
// Already in blockName.attribute format, verify the block exists
const parts = outputId.split('.')
if (parts.length >= 2) {
const blockName = parts[0]
// Check if a block with this name exists
const block = blocks.find(
(b) => b.name && normalizeName(b.name) === normalizeName(blockName)
)
if (!block) {
// Block not found (deleted), return null to filter out
return null
}
}

View File

@@ -1,17 +1,14 @@
import { db } from '@sim/db'
import { permissions, userStats, workflow as workflowTable, workspace } from '@sim/db/schema'
import { permissions, userStats, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import type { InferSelectModel } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceWithOwner, type PermissionType } from '@/lib/workspaces/permissions/utils'
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('WorkflowUtils')
type WorkflowSelection = InferSelectModel<typeof workflowTable>
export async function getWorkflowById(id: string) {
const rows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1)
@@ -44,11 +41,7 @@ export async function getWorkflowAccessContext(
let workspacePermission: PermissionType | null = null
if (workflow.workspaceId) {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workflow.workspaceId))
.limit(1)
const workspaceRow = await getWorkspaceWithOwner(workflow.workspaceId)
workspaceOwnerId = workspaceRow?.ownerId ?? null
@@ -147,7 +140,6 @@ export const workflowHasResponseBlock = (executionResult: ExecutionResult): bool
return responseBlock !== undefined
}
// Create a HTTP response from response block
export const createHttpResponseFromBlock = (executionResult: ExecutionResult): NextResponse => {
const { data = {}, status = 200, headers = {} } = executionResult.output

View File

@@ -40,11 +40,15 @@ vi.mock('drizzle-orm', () => drizzleOrmMock)
import { db } from '@sim/db'
import {
checkWorkspaceAccess,
getManageableWorkspaces,
getUserEntityPermissions,
getUsersWithPermissions,
getWorkspaceById,
getWorkspaceWithOwner,
hasAdminPermission,
hasWorkspaceAdminAccess,
workspaceExists,
} from '@/lib/workspaces/permissions/utils'
const mockDb = db as any
@@ -610,4 +614,209 @@ describe('Permission Utils', () => {
expect(result).toEqual([])
})
})
describe('getWorkspaceById', () => {
it.concurrent('should return workspace when it exists', async () => {
const chain = createMockChain([{ id: 'workspace123' }])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceById('workspace123')
expect(result).toEqual({ id: 'workspace123' })
})
it.concurrent('should return null when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceById('non-existent')
expect(result).toBeNull()
})
it.concurrent('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceById('')
expect(result).toBeNull()
})
})
describe('getWorkspaceWithOwner', () => {
it.concurrent('should return workspace with owner when it exists', async () => {
const chain = createMockChain([{ id: 'workspace123', ownerId: 'owner456' }])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceWithOwner('workspace123')
expect(result).toEqual({ id: 'workspace123', ownerId: 'owner456' })
})
it.concurrent('should return null when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceWithOwner('non-existent')
expect(result).toBeNull()
})
it.concurrent('should handle workspace with null owner ID', async () => {
const chain = createMockChain([{ id: 'workspace123', ownerId: null }])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceWithOwner('workspace123')
expect(result).toEqual({ id: 'workspace123', ownerId: null })
})
})
describe('workspaceExists', () => {
it.concurrent('should return true when workspace exists', async () => {
const chain = createMockChain([{ id: 'workspace123' }])
mockDb.select.mockReturnValue(chain)
const result = await workspaceExists('workspace123')
expect(result).toBe(true)
})
it.concurrent('should return false when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await workspaceExists('non-existent')
expect(result).toBe(false)
})
it.concurrent('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await workspaceExists('')
expect(result).toBe(false)
})
})
describe('checkWorkspaceAccess', () => {
it('should return exists=false when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('non-existent', 'user123')
expect(result).toEqual({
exists: false,
hasAccess: false,
canWrite: false,
workspace: null,
})
})
it('should return full access when user is workspace owner', async () => {
const chain = createMockChain([{ id: 'workspace123', ownerId: 'user123' }])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result).toEqual({
exists: true,
hasAccess: true,
canWrite: true,
workspace: { id: 'workspace123', ownerId: 'user123' },
})
})
it('should return hasAccess=false when user has no permissions', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([]) // No permissions
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(false)
expect(result.canWrite).toBe(false)
})
it('should return canWrite=true when user has admin permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'admin' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(true)
})
it('should return canWrite=true when user has write permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'write' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(true)
})
it('should return canWrite=false when user has read permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'read' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(false)
})
it('should handle empty user ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('workspace123', '')
expect(result.exists).toBe(false)
expect(result.hasAccess).toBe(false)
})
it('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('', 'user123')
expect(result.exists).toBe(false)
expect(result.hasAccess).toBe(false)
})
})
})

View File

@@ -3,6 +3,112 @@ import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/s
import { and, eq } from 'drizzle-orm'
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
export interface WorkspaceBasic {
id: string
}
export interface WorkspaceWithOwner {
id: string
ownerId: string
}
export interface WorkspaceAccess {
exists: boolean
hasAccess: boolean
canWrite: boolean
workspace: WorkspaceWithOwner | null
}
/**
* Get a workspace by ID (basic existence check)
*
* @param workspaceId - The workspace ID to look up
* @returns The workspace if found, null otherwise
*/
export async function getWorkspaceById(workspaceId: string): Promise<WorkspaceBasic | null> {
const [ws] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
return ws || null
}
/**
* Get a workspace with owner info by ID
*
* @param workspaceId - The workspace ID to look up
* @returns The workspace with owner info if found, null otherwise
*/
export async function getWorkspaceWithOwner(
workspaceId: string
): Promise<WorkspaceWithOwner | null> {
const [ws] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
return ws || null
}
/**
* Check if a workspace exists
*
* @param workspaceId - The workspace ID to check
* @returns True if the workspace exists, false otherwise
*/
export async function workspaceExists(workspaceId: string): Promise<boolean> {
const ws = await getWorkspaceById(workspaceId)
return ws !== null
}
/**
* Check workspace access for a user
*
* Verifies the workspace exists and the user has access to it.
* Returns access level (read/write) based on ownership and permissions.
*
* @param workspaceId - The workspace ID to check
* @param userId - The user ID to check access for
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
*/
export async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceAccess> {
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws) {
return { exists: false, hasAccess: false, canWrite: false, workspace: null }
}
if (ws.ownerId === userId) {
return { exists: true, hasAccess: true, canWrite: true, workspace: ws }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { exists: true, hasAccess: false, canWrite: false, workspace: ws }
}
const canWrite =
permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin'
return { exists: true, hasAccess: true, canWrite, workspace: ws }
}
/**
* Get the highest permission level a user has for a specific entity
@@ -111,17 +217,13 @@ export async function hasWorkspaceAdminAccess(
userId: string,
workspaceId: string
): Promise<boolean> {
const workspaceResult = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
const ws = await getWorkspaceWithOwner(workspaceId)
if (workspaceResult.length === 0) {
if (!ws) {
return false
}
if (workspaceResult[0].ownerId === userId) {
if (ws.ownerId === userId) {
return true
}

View File

@@ -23,6 +23,7 @@
"generate-docs": "bun run ../../scripts/generate-docs.ts"
},
"dependencies": {
"@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "^0.39.0",
"@aws-sdk/client-bedrock-runtime": "3.940.0",
"@aws-sdk/client-dynamodb": "3.940.0",

View File

@@ -42,10 +42,6 @@ import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/rememb
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website'
import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents'
import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
@@ -120,12 +116,8 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
get_trigger_blocks: (id) => new GetTriggerBlocksClientTool(id),
search_online: (id) => new SearchOnlineClientTool(id),
search_documentation: (id) => new SearchDocumentationClientTool(id),
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
search_patterns: (id) => new SearchPatternsClientTool(id),
search_errors: (id) => new SearchErrorsClientTool(id),
scrape_page: (id) => new ScrapePageClientTool(id),
get_page_contents: (id) => new GetPageContentsClientTool(id),
crawl_website: (id) => new CrawlWebsiteClientTool(id),
remember_debug: (id) => new RememberDebugClientTool(id),
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id),
get_credentials: (id) => new GetCredentialsClientTool(id),
@@ -182,12 +174,8 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
get_trigger_blocks: (GetTriggerBlocksClientTool as any)?.metadata,
search_online: (SearchOnlineClientTool as any)?.metadata,
search_documentation: (SearchDocumentationClientTool as any)?.metadata,
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
search_patterns: (SearchPatternsClientTool as any)?.metadata,
search_errors: (SearchErrorsClientTool as any)?.metadata,
scrape_page: (ScrapePageClientTool as any)?.metadata,
get_page_contents: (GetPageContentsClientTool as any)?.metadata,
crawl_website: (CrawlWebsiteClientTool as any)?.metadata,
remember_debug: (RememberDebugClientTool as any)?.metadata,
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
get_credentials: (GetCredentialsClientTool as any)?.metadata,
@@ -2445,10 +2433,9 @@ export const useCopilotStore = create<CopilotStore>()(
// If already sending a message, queue this one instead
if (isSendingMessage) {
get().addToQueue(message, { fileAttachments, contexts, messageId })
get().addToQueue(message, { fileAttachments, contexts })
logger.info('[Copilot] Message queued (already sending)', {
queueLength: get().messageQueue.length + 1,
originalMessageId: messageId,
})
return
}
@@ -2524,13 +2511,6 @@ export const useCopilotStore = create<CopilotStore>()(
// Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
// Extract slash commands from contexts (lowercase) and filter them out from contexts
const commands = contexts
?.filter((c) => c.kind === 'slash_command' && 'command' in c)
.map((c) => (c as any).command.toLowerCase()) as string[] | undefined
const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command')
const result = await sendStreamingMessage({
message: messageToSend,
userMessageId: userMessage.id,
@@ -2542,8 +2522,7 @@ export const useCopilotStore = create<CopilotStore>()(
createNewChat: !currentChat,
stream,
fileAttachments,
contexts: filteredContexts,
commands: commands?.length ? commands : undefined,
contexts,
abortSignal: abortController.signal,
})
@@ -3182,12 +3161,8 @@ export const useCopilotStore = create<CopilotStore>()(
// Process next message in queue if any
const nextInQueue = get().messageQueue[0]
if (nextInQueue) {
// Use originalMessageId if available (from edit/resend), otherwise use queue entry id
const messageIdToUse = nextInQueue.originalMessageId || nextInQueue.id
logger.info('[Queue] Processing next queued message', {
id: nextInQueue.id,
originalMessageId: nextInQueue.originalMessageId,
messageIdToUse,
queueLength: get().messageQueue.length,
})
// Remove from queue and send
@@ -3198,7 +3173,7 @@ export const useCopilotStore = create<CopilotStore>()(
stream: true,
fileAttachments: nextInQueue.fileAttachments,
contexts: nextInQueue.contexts,
messageId: messageIdToUse,
messageId: nextInQueue.id,
})
}, 100)
}
@@ -3640,12 +3615,10 @@ export const useCopilotStore = create<CopilotStore>()(
fileAttachments: options?.fileAttachments,
contexts: options?.contexts,
queuedAt: Date.now(),
originalMessageId: options?.messageId,
}
set({ messageQueue: [...get().messageQueue, queuedMessage] })
logger.info('[Queue] Message added to queue', {
id: queuedMessage.id,
originalMessageId: options?.messageId,
queueLength: get().messageQueue.length,
})
},
@@ -3686,15 +3659,12 @@ export const useCopilotStore = create<CopilotStore>()(
await new Promise((resolve) => setTimeout(resolve, 50))
}
// Use originalMessageId if available (from edit/resend), otherwise use queue entry id
const messageIdToUse = message.originalMessageId || message.id
// Send the message
await get().sendMessage(message.content, {
stream: true,
fileAttachments: message.fileAttachments,
contexts: message.contexts,
messageId: messageIdToUse,
messageId: message.id,
})
},

View File

@@ -70,8 +70,6 @@ export interface QueuedMessage {
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
queuedAt: number
/** Original messageId to use when processing (for edit/resend flows) */
originalMessageId?: string
}
// Contexts attached to a user message
@@ -85,7 +83,6 @@ export type ChatContext =
| { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'templates'; templateId?: string; label: string }
| { kind: 'docs'; label: string }
| { kind: 'slash_command'; command: string; label: string }
import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
@@ -252,8 +249,6 @@ export interface CopilotActions {
options?: {
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
/** Original messageId to preserve (for edit/resend flows) */
messageId?: string
}
) => void
removeFromQueue: (id: string) => void

View File

@@ -0,0 +1,55 @@
import type { ToolConfig } from '@/tools/types'
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
id: 'a2a_cancel_task',
name: 'A2A Cancel Task',
description: 'Cancel a running A2A task.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to cancel',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/cancel-task',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2ACancelTaskParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
cancelled: {
type: 'boolean',
description: 'Whether cancellation was successful',
},
state: {
type: 'string',
description: 'Task state after cancellation',
},
},
}

View File

@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import type { A2ADeletePushNotificationParams, A2ADeletePushNotificationResponse } from './types'
export const a2aDeletePushNotificationTool: ToolConfig<
A2ADeletePushNotificationParams,
A2ADeletePushNotificationResponse
> = {
id: 'a2a_delete_push_notification',
name: 'A2A Delete Push Notification',
description: 'Delete the push notification webhook configuration for a task.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to delete notification config for',
},
pushNotificationConfigId: {
type: 'string',
description:
'Push notification configuration ID to delete (optional - server can derive from taskId)',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/delete-push-notification',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
pushNotificationConfigId: params.pushNotificationConfigId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
success: {
type: 'boolean',
description: 'Whether deletion was successful',
},
},
}

View File

@@ -0,0 +1,73 @@
import type { ToolConfig } from '@/tools/types'
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
id: 'a2a_get_agent_card',
name: 'A2A Get Agent Card',
description: 'Fetch the Agent Card (discovery document) for an A2A agent.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
apiKey: {
type: 'string',
description: 'API key for authentication (if required)',
},
},
request: {
url: '/api/tools/a2a/get-agent-card',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
agentUrl: params.agentUrl,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
name: {
type: 'string',
description: 'Agent name',
},
description: {
type: 'string',
description: 'Agent description',
},
url: {
type: 'string',
description: 'Agent endpoint URL',
},
version: {
type: 'string',
description: 'Agent version',
},
capabilities: {
type: 'object',
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
},
skills: {
type: 'array',
description: 'Skills the agent can perform',
},
defaultInputModes: {
type: 'array',
description: 'Default input modes (text, file, data)',
},
defaultOutputModes: {
type: 'array',
description: 'Default output modes (text, file, data)',
},
},
}

View File

@@ -0,0 +1,77 @@
import type { ToolConfig } from '@/tools/types'
import type { A2AGetPushNotificationParams, A2AGetPushNotificationResponse } from './types'
export const a2aGetPushNotificationTool: ToolConfig<
A2AGetPushNotificationParams,
A2AGetPushNotificationResponse
> = {
id: 'a2a_get_push_notification',
name: 'A2A Get Push Notification',
description: 'Get the push notification webhook configuration for a task.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to get notification config for',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/get-push-notification',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: {
exists: false,
},
error: data.error || 'Failed to get push notification',
}
}
return {
success: data.success,
output: data.output,
error: data.error,
}
},
outputs: {
url: {
type: 'string',
description: 'Configured webhook URL',
},
token: {
type: 'string',
description: 'Token for webhook validation',
},
exists: {
type: 'boolean',
description: 'Whether a push notification config exists',
},
},
}

View File

@@ -0,0 +1,72 @@
import type { ToolConfig } from '@/tools/types'
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
id: 'a2a_get_task',
name: 'A2A Get Task',
description: 'Query the status of an existing A2A task.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to query',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',
},
},
request: {
url: '/api/tools/a2a/get-task',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2AGetTaskParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
historyLength: params.historyLength,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
taskId: {
type: 'string',
description: 'Task ID',
},
contextId: {
type: 'string',
description: 'Context ID',
},
state: {
type: 'string',
description: 'Task state',
},
artifacts: {
type: 'array',
description: 'Output artifacts',
},
history: {
type: 'array',
description: 'Message history',
},
},
}

View File

@@ -0,0 +1,21 @@
import { a2aCancelTaskTool } from './cancel_task'
import { a2aDeletePushNotificationTool } from './delete_push_notification'
import { a2aGetAgentCardTool } from './get_agent_card'
import { a2aGetPushNotificationTool } from './get_push_notification'
import { a2aGetTaskTool } from './get_task'
import { a2aResubscribeTool } from './resubscribe'
import { a2aSendMessageTool } from './send_message'
import { a2aSendMessageStreamTool } from './send_message_stream'
import { a2aSetPushNotificationTool } from './set_push_notification'
export {
a2aCancelTaskTool,
a2aDeletePushNotificationTool,
a2aGetAgentCardTool,
a2aGetPushNotificationTool,
a2aGetTaskTool,
a2aResubscribeTool,
a2aSendMessageTool,
a2aSendMessageStreamTool,
a2aSetPushNotificationTool,
}

View File

@@ -0,0 +1,87 @@
import type { ToolConfig } from '@/tools/types'
import type { A2AResubscribeParams, A2AResubscribeResponse } from './types'
export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribeResponse> = {
id: 'a2a_resubscribe',
name: 'A2A Resubscribe',
description: 'Reconnect to an ongoing A2A task stream after connection interruption.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to resubscribe to',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/resubscribe',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2AResubscribeParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
taskId: '',
state: 'failed' as const,
isRunning: false,
},
error: data.error || 'Failed to resubscribe',
}
}
return {
success: true,
output: data.output,
}
},
outputs: {
taskId: {
type: 'string',
description: 'Task ID',
},
contextId: {
type: 'string',
description: 'Context ID',
},
state: {
type: 'string',
description: 'Current task state',
},
isRunning: {
type: 'boolean',
description: 'Whether the task is still running',
},
artifacts: {
type: 'array',
description: 'Output artifacts',
},
history: {
type: 'array',
description: 'Message history',
},
},
}

View File

@@ -0,0 +1,72 @@
import type { ToolConfig } from '@/tools/types'
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
id: 'a2a_send_message',
name: 'A2A Send Message',
description: 'Send a message to an external A2A-compatible agent.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
message: {
type: 'string',
required: true,
description: 'Message to send to the agent',
},
taskId: {
type: 'string',
description: 'Task ID for continuing an existing task',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/send-message',
method: 'POST',
headers: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
content: {
type: 'string',
description: 'The text response from the agent',
},
taskId: {
type: 'string',
description: 'Task ID for follow-up interactions',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
state: {
type: 'string',
description: 'Task state',
},
artifacts: {
type: 'array',
description: 'Structured output artifacts',
},
history: {
type: 'array',
description: 'Full message history',
},
},
}

View File

@@ -0,0 +1,81 @@
import type { ToolConfig } from '@/tools/types'
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
export const a2aSendMessageStreamTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
id: 'a2a_send_message_stream',
name: 'A2A Send Message (Streaming)',
description: 'Send a message to an external A2A-compatible agent with real-time streaming.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
message: {
type: 'string',
required: true,
description: 'Message to send to the agent',
},
taskId: {
type: 'string',
description: 'Task ID for continuing an existing task',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/send-message-stream',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
agentUrl: params.agentUrl,
message: params.message,
taskId: params.taskId,
contextId: params.contextId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
content: {
type: 'string',
description: 'The text response from the agent',
},
taskId: {
type: 'string',
description: 'Task ID for follow-up interactions',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
state: {
type: 'string',
description: 'Task state',
},
artifacts: {
type: 'array',
description: 'Structured output artifacts',
},
history: {
type: 'array',
description: 'Full message history',
},
},
}

View File

@@ -0,0 +1,92 @@
import type { ToolConfig } from '@/tools/types'
import type { A2ASetPushNotificationParams, A2ASetPushNotificationResponse } from './types'
export const a2aSetPushNotificationTool: ToolConfig<
A2ASetPushNotificationParams,
A2ASetPushNotificationResponse
> = {
id: 'a2a_set_push_notification',
name: 'A2A Set Push Notification',
description: 'Configure a webhook to receive task update notifications.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
taskId: {
type: 'string',
required: true,
description: 'Task ID to configure notifications for',
},
webhookUrl: {
type: 'string',
required: true,
description: 'HTTPS webhook URL to receive notifications',
},
token: {
type: 'string',
description: 'Token for webhook validation',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/set-push-notification',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2ASetPushNotificationParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
webhookUrl: params.webhookUrl,
token: params.token,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
url: '',
success: false,
},
error: data.error || 'Failed to set push notification',
}
}
return {
success: true,
output: {
url: data.output.url,
token: data.output.token,
success: data.output.success,
},
}
},
outputs: {
url: {
type: 'string',
description: 'Configured webhook URL',
},
token: {
type: 'string',
description: 'Token for webhook validation',
},
success: {
type: 'boolean',
description: 'Whether configuration was successful',
},
},
}

135
apps/sim/tools/a2a/types.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { Artifact, Message, TaskState } from '@a2a-js/sdk'
import type { ToolResponse } from '@/tools/types'
export interface A2AGetAgentCardParams {
agentUrl: string
apiKey?: string
}
export interface A2AGetAgentCardResponse extends ToolResponse {
output: {
name: string
description?: string
url: string
version: string
capabilities?: {
streaming?: boolean
pushNotifications?: boolean
stateTransitionHistory?: boolean
}
skills?: Array<{
id: string
name: string
description?: string
}>
}
}
export interface A2ASendMessageParams {
agentUrl: string
message: string
taskId?: string
contextId?: string
apiKey?: string
}
export interface A2ASendMessageResponse extends ToolResponse {
output: {
content: string
taskId: string
contextId?: string
state: TaskState
artifacts?: Artifact[]
history?: Message[]
}
}
export interface A2AGetTaskParams {
agentUrl: string
taskId: string
apiKey?: string
historyLength?: number
}
export interface A2AGetTaskResponse extends ToolResponse {
output: {
taskId: string
contextId?: string
state: TaskState
artifacts?: Artifact[]
history?: Message[]
}
}
export interface A2ACancelTaskParams {
agentUrl: string
taskId: string
apiKey?: string
}
export interface A2ACancelTaskResponse extends ToolResponse {
output: {
cancelled: boolean
state: TaskState
}
}
export interface A2AResubscribeParams {
agentUrl: string
taskId: string
apiKey?: string
}
export interface A2AResubscribeResponse extends ToolResponse {
output: {
taskId: string
contextId?: string
state: TaskState
isRunning: boolean
artifacts?: Artifact[]
history?: Message[]
}
}
export interface A2ASetPushNotificationParams {
agentUrl: string
taskId: string
webhookUrl: string
token?: string
apiKey?: string
}
export interface A2ASetPushNotificationResponse extends ToolResponse {
output: {
url: string
token?: string
success: boolean
}
}
export interface A2AGetPushNotificationParams {
agentUrl: string
taskId: string
apiKey?: string
}
export interface A2AGetPushNotificationResponse extends ToolResponse {
output: {
url?: string
token?: string
exists: boolean
}
}
export interface A2ADeletePushNotificationParams {
agentUrl: string
taskId: string
pushNotificationConfigId?: string
apiKey?: string
}
export interface A2ADeletePushNotificationResponse extends ToolResponse {
output: {
success: boolean
}
}

View File

@@ -1,3 +1,14 @@
import {
a2aCancelTaskTool,
a2aDeletePushNotificationTool,
a2aGetAgentCardTool,
a2aGetPushNotificationTool,
a2aGetTaskTool,
a2aResubscribeTool,
a2aSendMessageStreamTool,
a2aSendMessageTool,
a2aSetPushNotificationTool,
} from '@/tools/a2a'
import {
ahrefsBacklinksStatsTool,
ahrefsBacklinksTool,
@@ -1421,6 +1432,15 @@ import { sqsSendTool } from './sqs'
// Registry of all available tools
export const tools: Record<string, ToolConfig> = {
a2a_cancel_task: a2aCancelTaskTool,
a2a_delete_push_notification: a2aDeletePushNotificationTool,
a2a_get_agent_card: a2aGetAgentCardTool,
a2a_get_push_notification: a2aGetPushNotificationTool,
a2a_get_task: a2aGetTaskTool,
a2a_resubscribe: a2aResubscribeTool,
a2a_send_message: a2aSendMessageTool,
a2a_send_message_stream: a2aSendMessageStreamTool,
a2a_set_push_notification: a2aSetPushNotificationTool,
arxiv_search: arxivSearchTool,
arxiv_get_paper: arxivGetPaperTool,
arxiv_get_author_papers: arxivGetAuthorPapersTool,

View File

@@ -53,6 +53,7 @@
"name": "sim",
"version": "0.1.0",
"dependencies": {
"@a2a-js/sdk": "0.3.7",
"@anthropic-ai/sdk": "^0.39.0",
"@aws-sdk/client-bedrock-runtime": "3.940.0",
"@aws-sdk/client-dynamodb": "3.940.0",
@@ -318,6 +319,8 @@
"react-dom": "19.2.1",
},
"packages": {
"@a2a-js/sdk": ["@a2a-js/sdk@0.3.7", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["express"] }, "sha512-1WBghkOjgiKt4rPNje8jlB9VateVQXqyjlc887bY/H8yM82Hlf0+5JW8zB98BPExKAplI5XqtXVH980J6vqi+w=="],
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],

View File

@@ -0,0 +1,61 @@
CREATE TYPE "public"."a2a_task_status" AS ENUM('submitted', 'working', 'input-required', 'completed', 'failed', 'canceled', 'rejected', 'auth-required', 'unknown');--> statement-breakpoint
CREATE TABLE "a2a_agent" (
"id" text PRIMARY KEY NOT NULL,
"workspace_id" text NOT NULL,
"workflow_id" text NOT NULL,
"created_by" text NOT NULL,
"name" text NOT NULL,
"description" text,
"version" text DEFAULT '1.0.0' NOT NULL,
"capabilities" jsonb DEFAULT '{}' NOT NULL,
"skills" jsonb DEFAULT '[]' NOT NULL,
"authentication" jsonb DEFAULT '{}' NOT NULL,
"signatures" jsonb DEFAULT '[]',
"is_published" boolean DEFAULT false NOT NULL,
"published_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "a2a_push_notification_config" (
"id" text PRIMARY KEY NOT NULL,
"task_id" text NOT NULL,
"url" text NOT NULL,
"token" text,
"auth_schemes" jsonb DEFAULT '[]',
"auth_credentials" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "a2a_task" (
"id" text PRIMARY KEY NOT NULL,
"agent_id" text NOT NULL,
"session_id" text,
"status" "a2a_task_status" DEFAULT 'submitted' NOT NULL,
"messages" jsonb DEFAULT '[]' NOT NULL,
"artifacts" jsonb DEFAULT '[]',
"execution_id" text,
"metadata" jsonb DEFAULT '{}',
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"completed_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "a2a_agent" ADD CONSTRAINT "a2a_agent_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "a2a_agent" ADD CONSTRAINT "a2a_agent_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "a2a_agent" ADD CONSTRAINT "a2a_agent_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "a2a_push_notification_config" ADD CONSTRAINT "a2a_push_notification_config_task_id_a2a_task_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."a2a_task"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "a2a_task" ADD CONSTRAINT "a2a_task_agent_id_a2a_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."a2a_agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "a2a_agent_workspace_id_idx" ON "a2a_agent" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "a2a_agent_workflow_id_idx" ON "a2a_agent" USING btree ("workflow_id");--> statement-breakpoint
CREATE INDEX "a2a_agent_created_by_idx" ON "a2a_agent" USING btree ("created_by");--> statement-breakpoint
CREATE UNIQUE INDEX "a2a_agent_workspace_workflow_unique" ON "a2a_agent" USING btree ("workspace_id","workflow_id");--> statement-breakpoint
CREATE INDEX "a2a_push_notification_config_task_id_idx" ON "a2a_push_notification_config" USING btree ("task_id");--> statement-breakpoint
CREATE UNIQUE INDEX "a2a_push_notification_config_task_unique" ON "a2a_push_notification_config" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX "a2a_task_agent_id_idx" ON "a2a_task" USING btree ("agent_id");--> statement-breakpoint
CREATE INDEX "a2a_task_session_id_idx" ON "a2a_task" USING btree ("session_id");--> statement-breakpoint
CREATE INDEX "a2a_task_status_idx" ON "a2a_task" USING btree ("status");--> statement-breakpoint
CREATE INDEX "a2a_task_execution_id_idx" ON "a2a_task" USING btree ("execution_id");--> statement-breakpoint
CREATE INDEX "a2a_task_created_at_idx" ON "a2a_task" USING btree ("created_at");

File diff suppressed because it is too large Load Diff

View File

@@ -967,6 +967,13 @@
"when": 1768027253808,
"tag": "0138_faulty_gamma_corps",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1768260112533,
"tag": "0139_late_cargill",
"breakpoints": true
}
]
}

View File

@@ -1785,6 +1785,153 @@ export const workflowMcpTool = pgTable(
})
)
/**
* A2A Task State Enum (v0.2.6)
*/
export const a2aTaskStatusEnum = pgEnum('a2a_task_status', [
'submitted',
'working',
'input-required',
'completed',
'failed',
'canceled',
'rejected',
'auth-required',
'unknown',
])
/**
* A2A Agents - Workflows exposed as A2A-compatible agents
* These agents can be called by external A2A clients
*/
export const a2aAgent = pgTable(
'a2a_agent',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/** Agent name (used in Agent Card) */
name: text('name').notNull(),
/** Agent description */
description: text('description'),
/** Agent version */
version: text('version').notNull().default('1.0.0'),
/** Agent capabilities (streaming, pushNotifications, etc.) */
capabilities: jsonb('capabilities').notNull().default('{}'),
/** Agent skills derived from workflow */
skills: jsonb('skills').notNull().default('[]'),
/** Authentication configuration */
authentication: jsonb('authentication').notNull().default('{}'),
/** Agent card signatures for verification (v0.3) */
signatures: jsonb('signatures').default('[]'),
/** Whether the agent is published and discoverable */
isPublished: boolean('is_published').notNull().default(false),
/** When the agent was published */
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('a2a_agent_workspace_id_idx').on(table.workspaceId),
workflowIdIdx: index('a2a_agent_workflow_id_idx').on(table.workflowId),
createdByIdx: index('a2a_agent_created_by_idx').on(table.createdBy),
workspaceWorkflowUnique: uniqueIndex('a2a_agent_workspace_workflow_unique').on(
table.workspaceId,
table.workflowId
),
})
)
/**
* A2A Tasks - Tracks task state for A2A agent interactions (v0.3)
* Each task represents a conversation/interaction with an agent
*/
export const a2aTask = pgTable(
'a2a_task',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => a2aAgent.id, { onDelete: 'cascade' }),
/** Context ID for multi-turn conversations (maps to API contextId) */
sessionId: text('session_id'),
/** Task state */
status: a2aTaskStatusEnum('status').notNull().default('submitted'),
/** Message history (maps to API history, array of TaskMessage) */
messages: jsonb('messages').notNull().default('[]'),
/** Structured output artifacts */
artifacts: jsonb('artifacts').default('[]'),
/** Link to workflow execution */
executionId: text('execution_id'),
/** Additional metadata */
metadata: jsonb('metadata').default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
},
(table) => ({
agentIdIdx: index('a2a_task_agent_id_idx').on(table.agentId),
sessionIdIdx: index('a2a_task_session_id_idx').on(table.sessionId),
statusIdx: index('a2a_task_status_idx').on(table.status),
executionIdIdx: index('a2a_task_execution_id_idx').on(table.executionId),
createdAtIdx: index('a2a_task_created_at_idx').on(table.createdAt),
})
)
/**
* A2A Push Notification Config - Webhook configuration for task updates
* Stores push notification webhooks for async task updates
*/
export const a2aPushNotificationConfig = pgTable(
'a2a_push_notification_config',
{
id: text('id').primaryKey(),
taskId: text('task_id')
.notNull()
.references(() => a2aTask.id, { onDelete: 'cascade' }),
/** Webhook URL for notifications */
url: text('url').notNull(),
/** Optional token for client-side validation */
token: text('token'),
/** Authentication schemes (e.g., ['bearer', 'apiKey']) */
authSchemes: jsonb('auth_schemes').default('[]'),
/** Authentication credentials hint */
authCredentials: text('auth_credentials'),
/** Whether this config is active */
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
taskIdIdx: index('a2a_push_notification_config_task_id_idx').on(table.taskId),
taskIdUnique: uniqueIndex('a2a_push_notification_config_task_unique').on(table.taskId),
})
)
export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed'])
export const usageLogSourceEnum = pgEnum('usage_log_source', ['workflow', 'wand', 'copilot'])