Skip to content

Commit e5e67b3

Browse files
authored
feat(circleback): add API tools, expanded block, and knowledge base connector (#7331)
* feat(circleback): add API tools, expanded block, and knowledge base connector * improvement(circleback): resolve relative pagination links against the API base * fix(docs-generator): drop circleback from the spread-only subBlocks fixture * improvement(circleback): address review findings on output schemas, assignee validation, and connector indexing * fix(circleback): reject unlimited-coercing sync caps and correct the block id output type * fix(circleback): scope dual-purpose action-item and search inputs per operation * fix(circleback): reject a zero sync cap that would coerce to unlimited
1 parent e100c5a commit e5e67b3

38 files changed

Lines changed: 5197 additions & 27 deletions

apps/docs/content/docs/integrations/circleback.mdx

Lines changed: 689 additions & 2 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/circleback.ts

Lines changed: 647 additions & 14 deletions
Large diffs are not rendered by default.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* Pins the listing-completeness signals the sync engine relies on to decide whether it may
3+
* hard-delete stored documents, plus the Link-header cursor parsing Circleback pagination
4+
* depends on. `listingCapped` and a truthful `hasMore` are the only things standing between
5+
* a partial listing and reconciliation purging the rest of the knowledge base.
6+
*
7+
* @vitest-environment node
8+
*/
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
12+
13+
vi.mock('@/lib/knowledge/documents/utils', () => ({
14+
fetchWithRetry: mockFetchWithRetry,
15+
VALIDATE_RETRY_OPTIONS: {},
16+
}))
17+
vi.mock('@/components/icons', () => ({ CirclebackIcon: () => null }))
18+
19+
import { circlebackConnector } from '@/connectors/circleback/circleback'
20+
21+
function meeting(id: string) {
22+
return {
23+
id,
24+
name: `Meeting ${id}`,
25+
createdAt: '2026-01-27T15:30:00Z',
26+
updatedAt: '2026-01-27T16:45:00Z',
27+
duration: 1800,
28+
tags: [{ id: 1, name: 'Customer' }],
29+
attendees: [{ profileId: 1, name: 'Oat Benson', email: 'oat@example.com' }],
30+
notes: '## Recap\nWe discussed the rollout.',
31+
actionItems: [
32+
{
33+
id: 10,
34+
title: 'Send follow-up',
35+
description: '',
36+
assignee: { name: 'Oat Benson', email: 'oat@example.com' },
37+
status: 'PENDING',
38+
},
39+
],
40+
insights: {},
41+
}
42+
}
43+
44+
/** Queue a single Circleback list response with an optional RFC 8288 next link. */
45+
function mockListResponse(body: unknown, nextCursor?: string, status = 200) {
46+
mockFetchWithRetry.mockResolvedValue({
47+
ok: status >= 200 && status < 300,
48+
status,
49+
headers: {
50+
get: (name: string) =>
51+
name.toLowerCase() === 'link' && nextCursor
52+
? `<https://circleback.ai/api/meetings?cursor=${nextCursor}>; rel="next"`
53+
: null,
54+
},
55+
json: async () => body,
56+
text: async () => JSON.stringify(body),
57+
} as unknown as Response)
58+
}
59+
60+
const list = (sourceConfig: Record<string, unknown>, syncContext: Record<string, unknown>) =>
61+
circlebackConnector.listDocuments('tok', sourceConfig, undefined, syncContext, undefined)
62+
63+
describe('circleback connector listing completeness', () => {
64+
beforeEach(() => {
65+
mockFetchWithRetry.mockReset()
66+
})
67+
68+
it('parses the next cursor from the Link header on a normal page', async () => {
69+
mockListResponse([meeting('m1')], 'cur_2')
70+
71+
const syncContext: Record<string, unknown> = {}
72+
const page = await list({}, syncContext)
73+
74+
expect(page.hasMore).toBe(true)
75+
expect(page.nextCursor).toBe('cur_2')
76+
expect(syncContext.listingCapped).toBeUndefined()
77+
})
78+
79+
it('reports a complete listing when there is no next link', async () => {
80+
mockListResponse([meeting('m1'), meeting('m2')])
81+
82+
const syncContext: Record<string, unknown> = {}
83+
const page = await list({}, syncContext)
84+
85+
expect(page.hasMore).toBe(false)
86+
expect(page.nextCursor).toBeUndefined()
87+
expect(syncContext.listingCapped).toBeUndefined()
88+
expect(page.documents).toHaveLength(2)
89+
})
90+
91+
it('never caps when no maxMeetings is configured', async () => {
92+
mockListResponse([meeting('m1'), meeting('m2')], 'cur_2')
93+
94+
const syncContext: Record<string, unknown> = {}
95+
await list({ maxMeetings: '' }, syncContext)
96+
97+
expect(syncContext.listingCapped).toBeUndefined()
98+
})
99+
100+
it('caps and flags when maxMeetings slices a page, hiding meetings that still exist', async () => {
101+
mockListResponse([meeting('m1'), meeting('m2'), meeting('m3')])
102+
103+
const syncContext: Record<string, unknown> = {}
104+
const page = await list({ maxMeetings: '2' }, syncContext)
105+
106+
expect(page.documents).toHaveLength(2)
107+
expect(syncContext.listingCapped).toBe(true)
108+
expect(page.hasMore).toBe(false)
109+
})
110+
111+
it('caps and flags when maxMeetings lands on a page boundary but more pages exist', async () => {
112+
mockListResponse([meeting('m1'), meeting('m2')], 'cur_2')
113+
114+
const syncContext: Record<string, unknown> = {}
115+
const page = await list({ maxMeetings: '2' }, syncContext)
116+
117+
expect(page.documents).toHaveLength(2)
118+
expect(syncContext.listingCapped).toBe(true)
119+
expect(page.hasMore).toBe(false)
120+
})
121+
122+
it('does NOT flag when the cap lands exactly on the last meeting and the source is exhausted', async () => {
123+
mockListResponse([meeting('m1'), meeting('m2')])
124+
125+
const syncContext: Record<string, unknown> = {}
126+
const page = await list({ maxMeetings: '2' }, syncContext)
127+
128+
expect(page.documents).toHaveLength(2)
129+
expect(syncContext.listingCapped).toBeUndefined()
130+
})
131+
132+
it('carries the cap across pages via totalDocsFetched', async () => {
133+
mockListResponse([meeting('m3'), meeting('m4')], 'cur_3')
134+
135+
const syncContext: Record<string, unknown> = { totalDocsFetched: 1 }
136+
const page = await list({ maxMeetings: '2' }, syncContext)
137+
138+
expect(page.documents).toHaveLength(1)
139+
expect(syncContext.totalDocsFetched).toBe(2)
140+
expect(syncContext.listingCapped).toBe(true)
141+
})
142+
})
143+
144+
describe('circleback connector request shaping and documents', () => {
145+
beforeEach(() => {
146+
mockFetchWithRetry.mockReset()
147+
})
148+
149+
it('applies only valid scope filters and defaults ownership to Mine', async () => {
150+
mockListResponse([])
151+
152+
await list({ ownership: 'Everything', tagIds: '3, oops, 7' }, {})
153+
154+
const url = new URL(mockFetchWithRetry.mock.calls[0][0] as string)
155+
expect(url.searchParams.get('ownership')).toBe('Mine')
156+
expect(url.searchParams.getAll('tagIds')).toEqual(['3', '7'])
157+
})
158+
159+
it('returns deferred plain-text stubs with a metadata-based hash and source URL', async () => {
160+
mockListResponse([meeting('m1')])
161+
162+
const page = await list({}, {})
163+
const stub = page.documents[0]
164+
165+
expect(stub.mimeType).toBe('text/plain')
166+
expect(stub.contentDeferred).toBe(true)
167+
expect(stub.contentHash).toBe('circleback:m1:2026-01-27T16:45:00Z:notes')
168+
expect(stub.sourceUrl).toBe('https://circleback.ai/meetings/m1')
169+
})
170+
171+
it('varies the content hash with the transcript mode so toggling it rehydrates', async () => {
172+
mockListResponse([meeting('m1')])
173+
const withTranscript = await list({ includeTranscript: 'true' }, {})
174+
expect(withTranscript.documents[0].contentHash).toBe(
175+
'circleback:m1:2026-01-27T16:45:00Z:transcript'
176+
)
177+
})
178+
179+
it('assembles notes and action items into content with an identical hash on getDocument', async () => {
180+
mockFetchWithRetry.mockResolvedValue({
181+
ok: true,
182+
status: 200,
183+
headers: { get: () => null },
184+
json: async () => meeting('m1'),
185+
text: async () => '',
186+
} as unknown as Response)
187+
188+
const doc = await circlebackConnector.getDocument('tok', {}, 'm1')
189+
190+
expect(doc).not.toBeNull()
191+
expect(doc?.contentDeferred).toBe(false)
192+
expect(doc?.contentHash).toBe('circleback:m1:2026-01-27T16:45:00Z:notes')
193+
expect(doc?.content).toContain('# Meeting m1')
194+
expect(doc?.content).toContain('We discussed the rollout.')
195+
expect(doc?.content).toContain('- [ ] Send follow-up (Oat Benson)')
196+
/* Transcript is opt-in, so only the meeting endpoint is called by default. */
197+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(1)
198+
})
199+
200+
it('returns null for a 404 but rethrows other failures so indexed documents survive', async () => {
201+
mockFetchWithRetry.mockResolvedValueOnce({
202+
ok: false,
203+
status: 404,
204+
headers: { get: () => null },
205+
json: async () => ({}),
206+
text: async () => '',
207+
} as unknown as Response)
208+
expect(await circlebackConnector.getDocument('tok', {}, 'gone')).toBeNull()
209+
210+
mockFetchWithRetry.mockResolvedValueOnce({
211+
ok: false,
212+
status: 500,
213+
headers: { get: () => null },
214+
json: async () => ({}),
215+
text: async () => '',
216+
} as unknown as Response)
217+
await expect(circlebackConnector.getDocument('tok', {}, 'm1')).rejects.toThrow('500')
218+
})
219+
220+
it('rejects caps that the parser would silently treat as unlimited', async () => {
221+
for (const bad of ['0', '0.5', 'Infinity', '-1', 'abc']) {
222+
const result = await circlebackConnector.validateConfig('tok', { maxMeetings: bad })
223+
expect(result.valid).toBe(false)
224+
}
225+
226+
mockFetchWithRetry.mockResolvedValue({
227+
ok: true,
228+
status: 200,
229+
headers: { get: () => null },
230+
json: async () => [],
231+
text: async () => '',
232+
} as unknown as Response)
233+
const ok = await circlebackConnector.validateConfig('tok', { maxMeetings: '25' })
234+
expect(ok.valid).toBe(true)
235+
})
236+
237+
it('maps metadata to declared tag keys', () => {
238+
const tags = circlebackConnector.mapTags?.({
239+
title: 'Weekly Sync',
240+
attendees: ['Oat Benson', 'Sam Lee'],
241+
tags: ['Customer'],
242+
meetingDate: '2026-01-27T15:30:00Z',
243+
duration: 1800,
244+
})
245+
246+
expect(tags?.title).toBe('Weekly Sync')
247+
expect(tags?.attendees).toBe('Oat Benson, Sam Lee')
248+
expect(tags?.tags).toBe('Customer')
249+
expect(tags?.meetingDate).toBeInstanceOf(Date)
250+
expect(tags?.duration).toBe(1800)
251+
})
252+
})

0 commit comments

Comments
 (0)