Skip to content

Commit 9749f43

Browse files
committed
fix(otter): harden connector listing and change detection, webhook dedup, and review findings
- Connector rejects a list page containing a conversation without an ID instead of dropping it, so reconciliation can never delete the conversation it stands for - Recent conversations (last 14 days) are re-read daily through a versioned listing hash plus a text digest; matchContentHash treats an identical re-read as equivalent, so only changed transcripts, action items, insights, or outlines are re-indexed - conversation_shared deliveries key idempotency on the delivery's retrieved_at, so a later re-share to the same source runs; completed retries still collapse - Typed triggers reject deliveries without an event name - Paginated tools and the connector share a strict has_more reader; Get Workspace returns workspaceId so it chains into List Workspace Conversations; the transcript-omitted notice never pushes content past the text limit - Regression tests for connector listing and change detection and for the webhook wire contract, each shown red on its reverted guard - Update the sim-search cancellation test fixture to a metadata-only Drive file; since #8352 PDFs are downloaded before the discussion read the test cancels in
1 parent 0184f00 commit 9749f43

19 files changed

Lines changed: 393 additions & 78 deletions

File tree

‎apps/docs/content/docs/integrations/otter.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ Get the Otter workspace of the authenticated user, including its ID, name, owner
271271

272272
| Parameter | Type | Description |
273273
| --------- | ---- | ----------- |
274-
| `id` | number | Workspace ID |
274+
| `workspaceId` | number | Workspace ID \(the id field\); pass it to List Workspace Conversations |
275275
| `name` | string | Workspace name |
276276
| `owner` | object | Workspace owner |
277277
| ↳ `id` | string | User ID |

‎apps/sim/blocks/blocks/otter.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,7 @@ export const OtterBlock: BlockConfig = {
252252
retrievedAt: { type: 'string', description: 'When Otter retrieved the data (ISO 8601)' },
253253
id: {
254254
type: 'string',
255-
description:
256-
'Conversation ID for Get Conversation, or the numeric workspace ID for Get Workspace',
255+
description: 'Conversation ID',
257256
},
258257
title: { type: 'string', description: 'Conversation title' },
259258
url: { type: 'string', description: 'URL to view the conversation in Otter' },
@@ -301,6 +300,10 @@ export const OtterBlock: BlockConfig = {
301300
type: 'json',
302301
description: 'Channel members (id, name, firstName, lastName, email)',
303302
},
303+
workspaceId: {
304+
type: 'number',
305+
description: 'Workspace ID from Get Workspace; pass it to List Workspace Conversations',
306+
},
304307
name: { type: 'string', description: 'Workspace name' },
305308
memberCount: { type: 'number', description: 'Number of workspace members' },
306309
handle: { type: 'string', description: 'Workspace handle (e.g., a domain)' },

‎apps/sim/connectors/otter/meta.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ export const otterConnectorMeta: ConnectorMeta = {
1515
},
1616

1717
/**
18-
* Otter exposes no conversation modification timestamp, so an explicit full
19-
* resync must rehydrate content even when list metadata is unchanged (for
20-
* example after a transcript is edited in Otter).
18+
* Otter exposes no conversation modification timestamp. Recent conversations
19+
* are re-read daily by the connector; an explicit full resync rehydrates older
20+
* ones whose list metadata is unchanged (for example after a transcript edit).
2121
*/
2222
rehydrateOnFullSync: true,
2323

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Pins the Otter connector's listing-completeness and change-detection contracts. Otter has no
3+
* modification timestamp and reconciliation deletes whatever a complete listing omits, so a
4+
* malformed page must fail rather than shrink, and edits must still reach recent conversations.
5+
*/
6+
import { knowledgeDocumentsUtilsMock } from '@sim/testing/mocks/knowledge-documents-utils.mock'
7+
import {
8+
knowledgeSecureFetchMock,
9+
knowledgeSecureFetchMockFns,
10+
} from '@sim/testing/mocks/knowledge-secure-fetch.mock'
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12+
13+
vi.mock('@/lib/knowledge/documents/utils', () => knowledgeDocumentsUtilsMock)
14+
vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => knowledgeSecureFetchMock)
15+
16+
import { otterConnector } from '@/connectors/otter/otter'
17+
18+
const mockFetchWithRetry = knowledgeSecureFetchMockFns.mockFetchWithRetry
19+
const NOW = new Date('2026-09-28T12:00:00Z')
20+
21+
/** A conversation shaped like the documented `GET /conversations` item. */
22+
function conversation(id: string, createdAt = '2026-09-27T10:00:00Z') {
23+
return {
24+
id,
25+
title: `Meeting ${id}`,
26+
url: `https://otter.ai/u/${id}`,
27+
owner: {
28+
id: 'u1',
29+
name: 'Jane Doe',
30+
first_name: 'Jane',
31+
last_name: 'Doe',
32+
email: 'jane@example.com',
33+
},
34+
created_at: createdAt,
35+
process_status: { abstract_summary: 'finished', action_item: 'finished', outline: 'finished' },
36+
calendar_guests: [{ name: 'John Doe', email: 'john@example.com' }],
37+
shared_emails: [],
38+
shared_channels: [],
39+
abstract_summary: 'Discussed the launch.',
40+
conf_join_url: null,
41+
}
42+
}
43+
44+
function detail(id: string, createdAt: string, actionItem: string, transcript = 'Jane 00:00\nHi') {
45+
return {
46+
meta: { retrieved_at: '2026-09-28T12:00:00Z' },
47+
data: {
48+
...conversation(id, createdAt),
49+
relationships: {
50+
action_items: [{ id: 'a1', text: actionItem, assignee: null, status: null }],
51+
insights: [],
52+
outline: [],
53+
transcript: { content: transcript, format: 'txt' },
54+
},
55+
},
56+
}
57+
}
58+
59+
const respond = (body: unknown, status = 200) =>
60+
new Response(typeof body === 'string' ? body : JSON.stringify(body), { status })
61+
62+
const list = (sourceConfig: Record<string, unknown> = {}, syncContext = {}) =>
63+
otterConnector.listDocuments('key', sourceConfig, undefined, syncContext)
64+
const get = (id: string, sourceConfig: Record<string, unknown> = {}) =>
65+
otterConnector.getDocument('key', sourceConfig, id)
66+
const match = (candidate: string, stored: string) =>
67+
otterConnector.matchContentHash!(candidate, stored)
68+
69+
describe('otter connector', () => {
70+
beforeEach(() => {
71+
vi.useFakeTimers({ toFake: ['Date'] })
72+
vi.setSystemTime(NOW)
73+
mockFetchWithRetry.mockReset()
74+
})
75+
afterEach(() => {
76+
vi.useRealTimers()
77+
})
78+
79+
describe('listing completeness', () => {
80+
it('rejects a page containing a conversation without an ID', async () => {
81+
mockFetchWithRetry.mockResolvedValueOnce(
82+
respond({
83+
meta: { has_more: false },
84+
data: [conversation('c1'), { ...conversation('c2'), id: null }],
85+
})
86+
)
87+
await expect(list()).rejects.toThrow('conversation without an ID')
88+
})
89+
90+
it('rejects a page whose has_more is not a boolean', async () => {
91+
mockFetchWithRetry.mockResolvedValueOnce(respond({ meta: {}, data: [conversation('c1')] }))
92+
await expect(list()).rejects.toThrow('missing meta.has_more')
93+
})
94+
95+
it('marks the listing capped only when the cap hid conversations', async () => {
96+
mockFetchWithRetry.mockResolvedValueOnce(
97+
respond({
98+
meta: { has_more: true, next_cursor: 'n2' },
99+
data: [conversation('c1'), conversation('c2')],
100+
})
101+
)
102+
const capped: Record<string, unknown> = {}
103+
const page = await list({ maxConversations: '2' }, capped)
104+
expect(page.hasMore).toBe(false)
105+
expect(capped.listingCapped).toBe(true)
106+
107+
mockFetchWithRetry.mockResolvedValueOnce(
108+
respond({ meta: { has_more: false }, data: [conversation('c1'), conversation('c2')] })
109+
)
110+
const exact: Record<string, unknown> = {}
111+
await list({ maxConversations: '2' }, exact)
112+
expect(exact.listingCapped).toBeUndefined()
113+
})
114+
})
115+
116+
describe('change detection', () => {
117+
it('keeps an older conversation current until its metadata changes', async () => {
118+
const created = '2026-08-01T10:00:00Z'
119+
mockFetchWithRetry.mockResolvedValueOnce(
120+
respond({ meta: { has_more: false }, data: [conversation('old', created)] })
121+
)
122+
const [stub] = (await list()).documents
123+
mockFetchWithRetry.mockResolvedValueOnce(respond(detail('old', created, 'Send pricing')))
124+
const hydrated = await get('old')
125+
126+
vi.setSystemTime(new Date('2026-09-29T12:00:00Z'))
127+
mockFetchWithRetry.mockResolvedValueOnce(
128+
respond({ meta: { has_more: false }, data: [conversation('old', created)] })
129+
)
130+
const [nextDay] = (await list()).documents
131+
132+
expect(match(nextDay.contentHash, hydrated!.contentHash)).toBe('current')
133+
expect(nextDay.contentHash).toBe(stub.contentHash)
134+
})
135+
136+
it('re-reads a recent conversation daily and re-indexes only changed text', async () => {
137+
const created = '2026-09-27T10:00:00Z'
138+
mockFetchWithRetry.mockResolvedValueOnce(respond(detail('new', created, 'Send pricing')))
139+
const stored = await get('new')
140+
141+
vi.setSystemTime(new Date('2026-09-29T12:00:00Z'))
142+
mockFetchWithRetry.mockResolvedValueOnce(
143+
respond({ meta: { has_more: false }, data: [conversation('new', created)] })
144+
)
145+
const [nextDay] = (await list()).documents
146+
expect(match(nextDay.contentHash, stored!.contentHash)).toBe('stale')
147+
148+
mockFetchWithRetry.mockResolvedValueOnce(respond(detail('new', created, 'Send pricing')))
149+
const unchanged = await get('new')
150+
expect(match(unchanged!.contentHash, stored!.contentHash)).toBe('equivalent')
151+
152+
mockFetchWithRetry.mockResolvedValueOnce(respond(detail('new', created, 'Send pricing v2')))
153+
const edited = await get('new')
154+
expect(match(edited!.contentHash, stored!.contentHash)).toBe('stale')
155+
})
156+
})
157+
158+
describe('hydration', () => {
159+
it('treats only a missing conversation as gone and rethrows transient failures', async () => {
160+
mockFetchWithRetry.mockResolvedValueOnce(respond({ error: 'not_found' }, 404))
161+
await expect(get('gone')).resolves.toBeNull()
162+
163+
mockFetchWithRetry.mockResolvedValueOnce(respond({ error: 'rate_limited' }, 429))
164+
await expect(get('busy')).rejects.toThrow('429')
165+
})
166+
167+
it('indexes the notes when the full response exceeds the download limit', async () => {
168+
const created = '2026-09-27T10:00:00Z'
169+
const oversized = detail('big', created, 'Send pricing', 'x'.repeat(17 * 1024 * 1024))
170+
mockFetchWithRetry
171+
.mockResolvedValueOnce(respond(oversized))
172+
.mockResolvedValueOnce(respond(detail('big', created, 'Send pricing', '')))
173+
174+
const document = await get('big')
175+
176+
expect(document?.skippedReason).toBeUndefined()
177+
expect(document?.content).toContain('Send pricing')
178+
expect(document?.content).toContain('Transcript omitted')
179+
expect(String(mockFetchWithRetry.mock.calls[1][0])).toContain(
180+
'include=action_items%2Cinsights%2Coutline'
181+
)
182+
})
183+
})
184+
})

0 commit comments

Comments
 (0)