@swarmclawai/swarmclaw 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +37 -1
  2. package/package.json +10 -3
  3. package/src/.env.local +4 -0
  4. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  5. package/src/app/api/a2a/route.ts +56 -0
  6. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  7. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  8. package/src/app/api/openclaw/sync/route.ts +1 -1
  9. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  10. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  11. package/src/app/api/swarmfeed/route.ts +37 -0
  12. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  13. package/src/app/protocols/page.tsx +16 -7
  14. package/src/app/swarmfeed/page.tsx +7 -0
  15. package/src/cli/index.js +19 -0
  16. package/src/cli/spec.js +8 -0
  17. package/src/components/agents/agent-avatar.tsx +2 -5
  18. package/src/components/agents/agent-sheet.tsx +10 -0
  19. package/src/components/auth/access-key-gate.tsx +25 -0
  20. package/src/components/layout/sidebar-rail.tsx +52 -0
  21. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  22. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  23. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  24. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  25. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  26. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  27. package/src/components/protocols/builder/node-palette.tsx +97 -0
  28. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  29. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  30. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  31. package/src/components/protocols/builder/node-types/index.ts +9 -0
  32. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  33. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  34. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  35. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  36. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  37. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  38. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  39. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  40. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  41. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  42. package/src/components/skills/skills-workspace.tsx +1 -9
  43. package/src/features/protocols/builder/hooks/index.ts +2 -0
  44. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  45. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  46. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  47. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  48. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  49. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  50. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  51. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  52. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  53. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  54. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  55. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  56. package/src/features/swarmfeed/compose-post.tsx +139 -0
  57. package/src/features/swarmfeed/feed-page.tsx +136 -0
  58. package/src/features/swarmfeed/post-card.tsx +114 -0
  59. package/src/features/swarmfeed/queries.ts +28 -0
  60. package/src/lib/a2a/agent-card.ts +61 -0
  61. package/src/lib/a2a/auth.ts +54 -0
  62. package/src/lib/a2a/client.ts +133 -0
  63. package/src/lib/a2a/discovery.ts +116 -0
  64. package/src/lib/a2a/handlers.ts +176 -0
  65. package/src/lib/a2a/json-rpc-router.ts +38 -0
  66. package/src/lib/a2a/types.ts +95 -0
  67. package/src/lib/app/navigation.ts +1 -0
  68. package/src/lib/app/view-constants.ts +9 -1
  69. package/src/lib/providers/anthropic.ts +111 -107
  70. package/src/lib/providers/openai.ts +146 -142
  71. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  72. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  73. package/src/lib/server/chat-execution/chat-execution-disabled.test.ts +14 -31
  74. package/src/lib/server/chat-execution/chat-execution-eval-history.test.ts +11 -34
  75. package/src/lib/server/chat-execution/chat-execution-grounding.test.ts +15 -34
  76. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +35 -36
  77. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  78. package/src/lib/server/extensions.ts +11 -0
  79. package/src/lib/server/knowledge-sources.test.ts +46 -0
  80. package/src/lib/server/knowledge-sources.ts +34 -16
  81. package/src/lib/server/openclaw/sync.ts +4 -4
  82. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  83. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  84. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  85. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  86. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  87. package/src/lib/server/protocols/protocol-types.ts +1 -0
  88. package/src/lib/server/session-tools/delegate.ts +151 -77
  89. package/src/lib/server/storage-auth.ts +10 -2
  90. package/src/lib/server/storage-normalization.ts +11 -0
  91. package/src/lib/server/storage.ts +100 -0
  92. package/src/lib/server/test-utils/run-with-temp-data-dir.ts +15 -2
  93. package/src/lib/server/working-state/service.test.ts +2 -3
  94. package/src/lib/server/working-state/service.ts +37 -6
  95. package/src/lib/swarmfeed-client.ts +157 -0
  96. package/src/lib/validation/schemas.ts +1 -1
  97. package/src/stores/slices/data-slice.ts +3 -0
  98. package/src/stores/use-approval-store.ts +4 -1
  99. package/src/types/agent.ts +31 -1
  100. package/src/types/index.ts +1 -0
  101. package/src/types/protocol.ts +19 -0
  102. package/src/types/session.ts +1 -1
  103. package/src/types/swarmfeed.ts +30 -0
@@ -0,0 +1,233 @@
1
+ import { create } from 'zustand'
2
+ import { devtools } from 'zustand/middleware'
3
+ import type { Node, Edge } from '@xyflow/react'
4
+ import type { ProtocolTemplate, ProtocolStepKind, ProtocolBranchCase, ProtocolRepeatConfig, ProtocolParallelConfig, ProtocolJoinConfig, ProtocolForEachConfig, ProtocolSubflowConfig, ProtocolSwarmConfig, ProtocolRunStepStatus } from '@/types'
5
+
6
+ export interface BuilderNodeData extends Record<string, unknown> {
7
+ label: string
8
+ kind: ProtocolStepKind
9
+ instructions?: string | null
10
+ turnLimit?: number | null
11
+ completionCriteria?: string | null
12
+ taskConfig?: { agentId?: string; title: string; description: string } | null
13
+ delegationConfig?: { agentId: string; message: string } | null
14
+ repeat?: ProtocolRepeatConfig | null
15
+ parallel?: ProtocolParallelConfig | null
16
+ join?: ProtocolJoinConfig | null
17
+ forEach?: ProtocolForEachConfig | null
18
+ subflow?: ProtocolSubflowConfig | null
19
+ swarm?: ProtocolSwarmConfig | null
20
+ branchCases?: ProtocolBranchCase[]
21
+ defaultNextStepId?: string | null
22
+ outputKey?: string | null
23
+ dependsOnStepIds?: string[]
24
+ runtimeStatus?: ProtocolRunStepStatus | null
25
+ }
26
+
27
+ export interface BuilderEdgeData extends Record<string, unknown> {
28
+ edgeType: 'default' | 'branch' | 'loop'
29
+ branchCaseId?: string | null
30
+ label?: string | null
31
+ isLoopback?: boolean
32
+ }
33
+
34
+ export interface ValidationError {
35
+ nodeId?: string
36
+ edgeId?: string
37
+ message: string
38
+ }
39
+
40
+ export interface ValidationWarning {
41
+ nodeId?: string
42
+ message: string
43
+ }
44
+
45
+ export type BuilderNode = Node<BuilderNodeData>
46
+ export type BuilderEdge = Edge<BuilderEdgeData>
47
+
48
+ interface UndoSnapshot {
49
+ nodes: BuilderNode[]
50
+ edges: BuilderEdge[]
51
+ }
52
+
53
+ const MAX_UNDO_HISTORY = 50
54
+
55
+ export interface ProtocolBuilderState {
56
+ nodes: BuilderNode[]
57
+ edges: BuilderEdge[]
58
+ selectedNodeId: string | null
59
+ selectedEdgeId: string | null
60
+ isPaletteOpen: boolean
61
+ isInspectorOpen: boolean
62
+ currentTemplate: ProtocolTemplate | null
63
+ isDirty: boolean
64
+ validationErrors: ValidationError[]
65
+ validationWarnings: ValidationWarning[]
66
+ undoStack: UndoSnapshot[]
67
+ redoStack: UndoSnapshot[]
68
+ activeRunId: string | null
69
+
70
+ setNodes: (nodes: BuilderNode[]) => void
71
+ setEdges: (edges: BuilderEdge[]) => void
72
+ selectNode: (nodeId: string | null) => void
73
+ selectEdge: (edgeId: string | null) => void
74
+ updateNodeData: (nodeId: string, data: Partial<BuilderNodeData>) => void
75
+ updateEdgeData: (edgeId: string, data: Partial<BuilderEdgeData>) => void
76
+ addNode: (node: BuilderNode) => void
77
+ deleteNode: (nodeId: string) => void
78
+ addEdge: (edge: BuilderEdge) => void
79
+ deleteEdge: (edgeId: string) => void
80
+ loadTemplate: (template: ProtocolTemplate, nodes: BuilderNode[], edges: BuilderEdge[]) => void
81
+ setValidation: (errors: ValidationError[], warnings: ValidationWarning[]) => void
82
+ setActiveRun: (runId: string | null) => void
83
+ setDirty: (dirty: boolean) => void
84
+ pushUndo: () => void
85
+ undo: () => void
86
+ redo: () => void
87
+ setPaletteOpen: (open: boolean) => void
88
+ setInspectorOpen: (open: boolean) => void
89
+ reset: () => void
90
+ }
91
+
92
+ const initialState = {
93
+ nodes: [] as BuilderNode[],
94
+ edges: [] as BuilderEdge[],
95
+ selectedNodeId: null,
96
+ selectedEdgeId: null,
97
+ isPaletteOpen: true,
98
+ isInspectorOpen: true,
99
+ currentTemplate: null,
100
+ isDirty: false,
101
+ validationErrors: [] as ValidationError[],
102
+ validationWarnings: [] as ValidationWarning[],
103
+ undoStack: [] as UndoSnapshot[],
104
+ redoStack: [] as UndoSnapshot[],
105
+ activeRunId: null,
106
+ }
107
+
108
+ export const useProtocolBuilderStore = create<ProtocolBuilderState>()(
109
+ devtools(
110
+ (set, get) => ({
111
+ ...initialState,
112
+
113
+ setNodes: (nodes) => set({ nodes, isDirty: true }),
114
+ setEdges: (edges) => set({ edges, isDirty: true }),
115
+
116
+ selectNode: (nodeId) =>
117
+ set({ selectedNodeId: nodeId, selectedEdgeId: null }),
118
+
119
+ selectEdge: (edgeId) =>
120
+ set({ selectedEdgeId: edgeId, selectedNodeId: null }),
121
+
122
+ updateNodeData: (nodeId, data) => {
123
+ const { nodes } = get()
124
+ set({
125
+ nodes: nodes.map((n) =>
126
+ n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
127
+ ),
128
+ isDirty: true,
129
+ })
130
+ },
131
+
132
+ updateEdgeData: (edgeId, data) => {
133
+ const { edges } = get()
134
+ set({
135
+ edges: edges.map((e) =>
136
+ e.id === edgeId ? { ...e, data: { ...e.data, ...data } as BuilderEdgeData } : e,
137
+ ),
138
+ isDirty: true,
139
+ })
140
+ },
141
+
142
+ addNode: (node) => {
143
+ const { nodes } = get()
144
+ set({ nodes: [...nodes, node], isDirty: true })
145
+ },
146
+
147
+ deleteNode: (nodeId) => {
148
+ const { nodes, edges } = get()
149
+ set({
150
+ nodes: nodes.filter((n) => n.id !== nodeId),
151
+ edges: edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
152
+ selectedNodeId: null,
153
+ isDirty: true,
154
+ })
155
+ },
156
+
157
+ addEdge: (edge) => {
158
+ const { edges } = get()
159
+ set({ edges: [...edges, edge], isDirty: true })
160
+ },
161
+
162
+ deleteEdge: (edgeId) => {
163
+ const { edges } = get()
164
+ set({
165
+ edges: edges.filter((e) => e.id !== edgeId),
166
+ selectedEdgeId: null,
167
+ isDirty: true,
168
+ })
169
+ },
170
+
171
+ loadTemplate: (template, nodes, edges) =>
172
+ set({
173
+ currentTemplate: template,
174
+ nodes,
175
+ edges,
176
+ isDirty: false,
177
+ validationErrors: [],
178
+ validationWarnings: [],
179
+ undoStack: [],
180
+ redoStack: [],
181
+ selectedNodeId: null,
182
+ selectedEdgeId: null,
183
+ }),
184
+
185
+ setValidation: (errors, warnings) =>
186
+ set({ validationErrors: errors, validationWarnings: warnings }),
187
+
188
+ setActiveRun: (runId) => set({ activeRunId: runId }),
189
+ setDirty: (dirty) => set({ isDirty: dirty }),
190
+
191
+ pushUndo: () => {
192
+ const { nodes, edges, undoStack } = get()
193
+ const snapshot: UndoSnapshot = { nodes: [...nodes], edges: [...edges] }
194
+ const trimmed = undoStack.length >= MAX_UNDO_HISTORY
195
+ ? undoStack.slice(1)
196
+ : undoStack
197
+ set({ undoStack: [...trimmed, snapshot], redoStack: [] })
198
+ },
199
+
200
+ undo: () => {
201
+ const { undoStack, nodes, edges } = get()
202
+ if (undoStack.length === 0) return
203
+ const prev = undoStack[undoStack.length - 1]
204
+ set({
205
+ nodes: prev.nodes,
206
+ edges: prev.edges,
207
+ undoStack: undoStack.slice(0, -1),
208
+ redoStack: [{ nodes, edges }, ...get().redoStack],
209
+ isDirty: true,
210
+ })
211
+ },
212
+
213
+ redo: () => {
214
+ const { redoStack, nodes, edges } = get()
215
+ if (redoStack.length === 0) return
216
+ const next = redoStack[0]
217
+ set({
218
+ nodes: next.nodes,
219
+ edges: next.edges,
220
+ redoStack: redoStack.slice(1),
221
+ undoStack: [...get().undoStack, { nodes, edges }],
222
+ isDirty: true,
223
+ })
224
+ },
225
+
226
+ setPaletteOpen: (open) => set({ isPaletteOpen: open }),
227
+ setInspectorOpen: (open) => set({ isInspectorOpen: open }),
228
+
229
+ reset: () => set(initialState),
230
+ }),
231
+ { name: 'protocol-builder' },
232
+ ),
233
+ )
@@ -0,0 +1,41 @@
1
+ import dagre from 'dagre'
2
+ import type { Node, Edge } from '@xyflow/react'
3
+ import type { BuilderNodeData, BuilderEdgeData } from '../protocol-builder-store'
4
+
5
+ const NODE_WIDTH = 180
6
+ const NODE_HEIGHT = 80
7
+ const RANK_SEP = 100
8
+ const NODE_SEP = 60
9
+
10
+ export function getNodeLayout(
11
+ nodes: Node<BuilderNodeData>[],
12
+ edges: Edge<BuilderEdgeData>[],
13
+ ): Node<BuilderNodeData>[] {
14
+ if (nodes.length === 0) return nodes
15
+
16
+ const g = new dagre.graphlib.Graph()
17
+ g.setGraph({ rankdir: 'TB', ranksep: RANK_SEP, nodesep: NODE_SEP })
18
+ g.setDefaultEdgeLabel(() => ({}))
19
+
20
+ for (const node of nodes) {
21
+ g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
22
+ }
23
+
24
+ for (const edge of edges) {
25
+ g.setEdge(edge.source, edge.target)
26
+ }
27
+
28
+ dagre.layout(g)
29
+
30
+ return nodes.map((node) => {
31
+ const pos = g.node(node.id)
32
+ if (!pos) return node
33
+ return {
34
+ ...node,
35
+ position: {
36
+ x: pos.x - NODE_WIDTH / 2,
37
+ y: pos.y - NODE_HEIGHT / 2,
38
+ },
39
+ }
40
+ })
41
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { ProtocolTemplate, ProtocolStepDefinition } from '@/types'
4
+ import { templateToNodes } from './template-to-nodes'
5
+ import { nodesToTemplate } from './nodes-to-template'
6
+
7
+ function makeTemplate(overrides: Partial<ProtocolTemplate> = {}): ProtocolTemplate {
8
+ return {
9
+ id: 'tpl-1',
10
+ name: 'Test Template',
11
+ description: 'A test template',
12
+ builtIn: false,
13
+ defaultPhases: [],
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ function makeStep(overrides: Partial<ProtocolStepDefinition> = {}): ProtocolStepDefinition {
19
+ return {
20
+ id: 'step-1',
21
+ kind: 'present',
22
+ label: 'Step One',
23
+ ...overrides,
24
+ }
25
+ }
26
+
27
+ // --- Round-trip: templateToNodes → nodesToTemplate ---
28
+
29
+ describe('nodesToTemplate — round-trip', () => {
30
+ it('round-trips a simple 3-step template: preserves step count and entryStepId', () => {
31
+ const steps: ProtocolStepDefinition[] = [
32
+ makeStep({ id: 'step-1', label: 'Step One', nextStepId: 'step-2' }),
33
+ makeStep({ id: 'step-2', label: 'Step Two', kind: 'decide', nextStepId: 'step-3' }),
34
+ makeStep({ id: 'step-3', label: 'Step Three', kind: 'summarize' }),
35
+ ]
36
+ const template = makeTemplate({ steps, entryStepId: 'step-1' })
37
+ const { nodes, edges } = templateToNodes(template)
38
+ const result = nodesToTemplate(nodes, edges, template)
39
+
40
+ assert.strictEqual(result.steps!.length, 3)
41
+ assert.deepStrictEqual(result.steps!.map((s) => s.id), ['step-1', 'step-2', 'step-3'])
42
+ assert.strictEqual(result.entryStepId, 'step-1')
43
+ })
44
+
45
+ it('preserves nextStepId from default edges', () => {
46
+ const steps: ProtocolStepDefinition[] = [
47
+ makeStep({ id: 'step-a', nextStepId: 'step-b' }),
48
+ makeStep({ id: 'step-b', nextStepId: 'step-c' }),
49
+ makeStep({ id: 'step-c' }),
50
+ ]
51
+ const template = makeTemplate({ steps })
52
+ const { nodes, edges } = templateToNodes(template)
53
+ const result = nodesToTemplate(nodes, edges, template)
54
+
55
+ const stepA = result.steps!.find((s) => s.id === 'step-a')
56
+ const stepB = result.steps!.find((s) => s.id === 'step-b')
57
+ const stepC = result.steps!.find((s) => s.id === 'step-c')
58
+
59
+ assert.strictEqual(stepA?.nextStepId, 'step-b')
60
+ assert.strictEqual(stepB?.nextStepId, 'step-c')
61
+ assert.strictEqual(stepC?.nextStepId, undefined)
62
+ })
63
+
64
+ it('preserves step data fields: instructions, turnLimit, completionCriteria, outputKey', () => {
65
+ const steps: ProtocolStepDefinition[] = [
66
+ makeStep({
67
+ id: 'step-rich',
68
+ kind: 'decide',
69
+ label: 'Rich Step',
70
+ instructions: 'Evaluate carefully',
71
+ turnLimit: 5,
72
+ completionCriteria: 'Decision made',
73
+ outputKey: 'decision_result',
74
+ dependsOnStepIds: ['step-prev'],
75
+ }),
76
+ ]
77
+ const template = makeTemplate({ steps })
78
+ const { nodes, edges } = templateToNodes(template)
79
+ const result = nodesToTemplate(nodes, edges, template)
80
+
81
+ const step = result.steps![0]
82
+ assert.strictEqual(step.id, 'step-rich')
83
+ assert.strictEqual(step.kind, 'decide')
84
+ assert.strictEqual(step.label, 'Rich Step')
85
+ assert.strictEqual(step.instructions, 'Evaluate carefully')
86
+ assert.strictEqual(step.turnLimit, 5)
87
+ assert.strictEqual(step.completionCriteria, 'Decision made')
88
+ assert.strictEqual(step.outputKey, 'decision_result')
89
+ assert.deepStrictEqual(step.dependsOnStepIds, ['step-prev'])
90
+ })
91
+
92
+ it('preserves branchCases and defaultNextStepId', () => {
93
+ const steps: ProtocolStepDefinition[] = [
94
+ makeStep({
95
+ id: 'branch-step',
96
+ kind: 'branch',
97
+ label: 'Branch',
98
+ branchCases: [
99
+ { id: 'case-yes', label: 'Yes', nextStepId: 'step-yes' },
100
+ { id: 'case-no', label: 'No', nextStepId: 'step-no' },
101
+ ],
102
+ defaultNextStepId: 'step-default',
103
+ }),
104
+ makeStep({ id: 'step-yes', label: 'Yes Path' }),
105
+ makeStep({ id: 'step-no', label: 'No Path' }),
106
+ makeStep({ id: 'step-default', label: 'Default Path' }),
107
+ ]
108
+ const template = makeTemplate({ steps })
109
+ const { nodes, edges } = templateToNodes(template)
110
+ const result = nodesToTemplate(nodes, edges, template)
111
+
112
+ const branch = result.steps!.find((s) => s.id === 'branch-step')
113
+ assert.strictEqual(branch?.branchCases!.length, 2)
114
+ assert.strictEqual(branch?.branchCases![0].id, 'case-yes')
115
+ assert.strictEqual(branch?.branchCases![0].nextStepId, 'step-yes')
116
+ assert.strictEqual(branch?.branchCases![1].id, 'case-no')
117
+ assert.strictEqual(branch?.branchCases![1].nextStepId, 'step-no')
118
+ assert.strictEqual(branch?.defaultNextStepId, 'step-default')
119
+ })
120
+
121
+ it('sets entryStepId from the first node', () => {
122
+ const steps: ProtocolStepDefinition[] = [
123
+ makeStep({ id: 'first-step', label: 'First' }),
124
+ makeStep({ id: 'second-step', label: 'Second' }),
125
+ ]
126
+ const template = makeTemplate({ steps, entryStepId: 'first-step' })
127
+ const { nodes, edges } = templateToNodes(template)
128
+ const result = nodesToTemplate(nodes, edges, template)
129
+
130
+ assert.strictEqual(result.entryStepId, nodes[0].id)
131
+ assert.strictEqual(result.entryStepId, 'first-step')
132
+ })
133
+ })
134
+
135
+ // --- Direct nodesToTemplate tests (no round-trip) ---
136
+
137
+ describe('nodesToTemplate — direct construction', () => {
138
+ it('preserves all originalTemplate fields except steps and entryStepId', () => {
139
+ const template = makeTemplate({
140
+ id: 'custom-id',
141
+ name: 'Custom Name',
142
+ description: 'Custom description',
143
+ builtIn: true,
144
+ tags: ['tag-a', 'tag-b'],
145
+ singleAgentAllowed: true,
146
+ steps: [makeStep({ id: 'step-x' })],
147
+ })
148
+ const { nodes, edges } = templateToNodes(template)
149
+ const result = nodesToTemplate(nodes, edges, template)
150
+
151
+ assert.strictEqual(result.id, 'custom-id')
152
+ assert.strictEqual(result.name, 'Custom Name')
153
+ assert.strictEqual(result.description, 'Custom description')
154
+ assert.strictEqual(result.builtIn, true)
155
+ assert.deepStrictEqual(result.tags, ['tag-a', 'tag-b'])
156
+ assert.strictEqual(result.singleAgentAllowed, true)
157
+ })
158
+
159
+ it('handles an empty nodes array without throwing', () => {
160
+ const template = makeTemplate({ steps: [makeStep({ id: 'step-1' })], entryStepId: 'step-1' })
161
+ const result = nodesToTemplate([], [], template)
162
+
163
+ assert.strictEqual(result.steps!.length, 0)
164
+ // entryStepId falls back to originalTemplate.entryStepId when nodes is empty
165
+ assert.strictEqual(result.entryStepId, 'step-1')
166
+ })
167
+
168
+ it('does not include nextStepId on steps with no outgoing default edge', () => {
169
+ const steps: ProtocolStepDefinition[] = [
170
+ makeStep({ id: 'lone-step', label: 'Lone Step' }),
171
+ ]
172
+ const template = makeTemplate({ steps })
173
+ const { nodes, edges } = templateToNodes(template)
174
+ const result = nodesToTemplate(nodes, edges, template)
175
+
176
+ const step = result.steps![0]
177
+ assert.strictEqual(step.nextStepId, undefined)
178
+ })
179
+ })
@@ -0,0 +1,49 @@
1
+ import type { Node, Edge } from '@xyflow/react'
2
+ import type { ProtocolTemplate, ProtocolStepDefinition } from '@/types'
3
+ import type { BuilderNodeData, BuilderEdgeData } from '../protocol-builder-store'
4
+
5
+ export function nodesToTemplate(
6
+ nodes: Node<BuilderNodeData>[],
7
+ edges: Edge<BuilderEdgeData>[],
8
+ originalTemplate: ProtocolTemplate,
9
+ ): ProtocolTemplate {
10
+ const steps: ProtocolStepDefinition[] = nodes.map((node) => {
11
+ const step: ProtocolStepDefinition = {
12
+ id: node.id,
13
+ kind: node.data.kind,
14
+ label: node.data.label,
15
+ }
16
+
17
+ if (node.data.instructions) step.instructions = node.data.instructions
18
+ if (node.data.turnLimit) step.turnLimit = node.data.turnLimit
19
+ if (node.data.completionCriteria) step.completionCriteria = node.data.completionCriteria
20
+ if (node.data.taskConfig) step.taskConfig = node.data.taskConfig
21
+ if (node.data.delegationConfig) step.delegationConfig = node.data.delegationConfig
22
+ if (node.data.repeat) step.repeat = node.data.repeat
23
+ if (node.data.parallel) step.parallel = node.data.parallel
24
+ if (node.data.join) step.join = node.data.join
25
+ if (node.data.forEach) step.forEach = node.data.forEach
26
+ if (node.data.subflow) step.subflow = node.data.subflow
27
+ if (node.data.swarm) step.swarm = node.data.swarm
28
+ if (node.data.branchCases?.length) step.branchCases = node.data.branchCases
29
+ if (node.data.defaultNextStepId) step.defaultNextStepId = node.data.defaultNextStepId
30
+ if (node.data.outputKey) step.outputKey = node.data.outputKey
31
+ if (node.data.dependsOnStepIds?.length) step.dependsOnStepIds = node.data.dependsOnStepIds
32
+
33
+ // Derive nextStepId from default edges
34
+ const defaultEdge = edges.find(
35
+ (e) => e.source === node.id && e.data?.edgeType === 'default',
36
+ )
37
+ if (defaultEdge) {
38
+ step.nextStepId = defaultEdge.target
39
+ }
40
+
41
+ return step
42
+ })
43
+
44
+ return {
45
+ ...originalTemplate,
46
+ steps,
47
+ entryStepId: nodes[0]?.id || originalTemplate.entryStepId,
48
+ }
49
+ }