Skip to content

Commit 0951bde

Browse files
authored
fix(google-chat): avoid unchanged transcript reindexing (#8925)
* fix(google-chat): avoid unchanged transcript reindexing * fix(google-chat): reject rounded fractional settings
1 parent 6511d43 commit 0951bde

5 files changed

Lines changed: 307 additions & 36 deletions

File tree

‎apps/sim/connectors/google-chat/google-chat.integration.ts‎

Lines changed: 205 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createServer } from 'node:http'
22
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { classifyExternalDoc, contentHashMatch } from '@/lib/knowledge/connectors/sync-primitives'
34
import { googleChatConnector } from '@/connectors/google-chat/google-chat'
45

56
const SPACE = {
@@ -16,6 +17,8 @@ let messageReads = 0
1617
let requestCount = 0
1718
let firstParameters = ''
1819
let now = Date.parse('2026-10-02T12:00:00Z')
20+
let activity = SPACE.lastActiveTime
21+
let body = 'New decision: review on Friday.'
1922

2023
const server = createServer((request, response) => {
2124
requestCount++
@@ -29,7 +32,7 @@ const server = createServer((request, response) => {
2932
return
3033
}
3134
if (url.pathname === '/v1/spaces') {
32-
send({ spaces: [SPACE] })
35+
send({ spaces: [{ ...SPACE, lastActiveTime: activity }] })
3336
return
3437
}
3538
if (url.pathname === `/v1/${SPACE.name}`) {
@@ -81,6 +84,28 @@ const server = createServer((request, response) => {
8184
})
8285
return
8386
}
87+
if (mode === 'projected') {
88+
const fields = url.searchParams.get('fields') ?? '*'
89+
if (fields === '*' || fields.includes('cards')) {
90+
send({ cards: 'x'.repeat(33 * 1024 * 1024) })
91+
return
92+
}
93+
if (
94+
![
95+
'nextPageToken',
96+
'messages',
97+
'createTime',
98+
'sender',
99+
'name',
100+
'displayName',
101+
'text',
102+
'fallbackText',
103+
].every((field) => fields.includes(field))
104+
) {
105+
send({ error: 'Transcript or continuation field omitted' }, 400)
106+
return
107+
}
108+
}
84109
if (mode === 'empty-page' && !token) {
85110
send({ nextPageToken: 'page-2' })
86111
return
@@ -89,14 +114,14 @@ const server = createServer((request, response) => {
89114
const cutoff = url.searchParams.get('filter')?.match(/"([^"]+)"/)?.[1]
90115
send({
91116
messages:
92-
cutoff && time <= cutoff
117+
cutoff && Date.parse(time) <= Date.parse(cutoff)
93118
? []
94119
: [
95120
{
96121
name: `${SPACE.name}/messages/${token ? 'older' : 'newer'}`,
97122
sender: { name: 'users/fixture-author' },
98-
createTime: time,
99-
text: token ? 'Older decision: launch is paused.' : 'New decision: review on Friday.',
123+
createTime: mode === 'unknown-time' ? undefined : time,
124+
text: token ? 'Older decision: launch is paused.' : body,
100125
},
101126
],
102127
nextPageToken:
@@ -123,6 +148,8 @@ beforeEach(() => {
123148
requestCount = 0
124149
firstParameters = ''
125150
now = Date.parse('2026-10-02T12:00:00Z')
151+
activity = SPACE.lastActiveTime
152+
body = 'New decision: review on Friday.'
126153
})
127154

128155
afterAll(async () => {
@@ -184,15 +211,176 @@ describe('Google Chat connector over real HTTP', () => {
184211
const first = await googleChatConnector.listDocuments(TOKEN, config, undefined, firstContext)
185212
now += 60_000
186213
const initial = await read(config, firstContext)
214+
if (!initial) throw new Error('Expected a space document')
187215
expect(initial?.content).toContain('New decision:')
188-
expect(initial?.contentHash).toBe(first.documents[0].contentHash)
216+
expect(
217+
contentHashMatch(
218+
first.documents[0].contentHash,
219+
initial.contentHash,
220+
googleChatConnector.matchContentHash
221+
)
222+
).toBe('current')
189223
now += 3 * 24 * 60 * 60 * 1000
190224
const nextContext = {}
191225
const next = await googleChatConnector.listDocuments(TOKEN, config, undefined, nextContext)
192226
expect(next.documents[0].contentHash).not.toBe(first.documents[0].contentHash)
193227
const expired = await read(config, nextContext)
228+
if (!expired) throw new Error('Expected a space document')
229+
expect(expired?.content).not.toContain('New decision:')
230+
expect(
231+
contentHashMatch(
232+
next.documents[0].contentHash,
233+
expired.contentHash,
234+
googleChatConnector.matchContentHash
235+
)
236+
).toBe('current')
237+
})
238+
239+
it('skips unchanged rolling-window spaces until the exact next message expiry', async () => {
240+
mode = 'window'
241+
vi.spyOn(Date, 'now').mockImplementation(() => now)
242+
const config = { lookbackDays: '2' }
243+
const context = {}
244+
await googleChatConnector.listDocuments(TOKEN, config, undefined, context)
245+
const initial = await read(config, context)
246+
if (!initial) throw new Error('Expected a space document')
247+
const existing = { id: 'stored', contentHash: initial.contentHash, storageKey: 'indexed' }
248+
const classify = async () => {
249+
const page = await googleChatConnector.listDocuments(TOKEN, config, undefined, {})
250+
return classifyExternalDoc(
251+
page.documents[0],
252+
existing,
253+
false,
254+
googleChatConnector.matchContentHash
255+
).type
256+
}
257+
now += 1
258+
expect(await classify()).toBe('unchanged')
259+
now = Date.parse('2026-10-03T11:59:59.999Z')
260+
expect(await classify()).toBe('unchanged')
261+
expect(messageReads).toBe(1)
262+
now = Date.parse('2026-10-02T11:59:59.999Z')
263+
expect(await classify()).toBe('update')
264+
now = Date.parse('2026-10-03T11:59:59.999Z')
265+
now += 1
266+
expect(await classify()).toBe('update')
267+
const expired = await read(config, {})
194268
expect(expired?.content).not.toContain('New decision:')
195-
expect(expired?.contentHash).toBe(next.documents[0].contentHash)
269+
expect(messageReads).toBe(2)
270+
})
271+
272+
it('keeps empty windows cheap but refreshes changed settings, full syncs, and legacy hashes', async () => {
273+
mode = 'window'
274+
now = Date.parse('2026-10-04T12:00:00Z')
275+
vi.spyOn(Date, 'now').mockImplementation(() => now)
276+
const config = { lookbackDays: '2' }
277+
const initial = await read(config, {})
278+
if (!initial) throw new Error('Expected a space document')
279+
const existing = { id: 'stored', contentHash: initial.contentHash, storageKey: 'indexed' }
280+
now += 24 * 60 * 60 * 1000
281+
const page = await googleChatConnector.listDocuments(TOKEN, config, undefined, {})
282+
const stub = page.documents[0]
283+
expect(
284+
classifyExternalDoc(stub, existing, false, googleChatConnector.matchContentHash).type
285+
).toBe('unchanged')
286+
expect(
287+
classifyExternalDoc(stub, existing, true, googleChatConnector.matchContentHash).type
288+
).toBe('update')
289+
expect(
290+
classifyExternalDoc(
291+
stub,
292+
{ ...existing, contentHash: 'gchat:v2:legacy' },
293+
false,
294+
googleChatConnector.matchContentHash
295+
).type
296+
).toBe('update')
297+
const changed = await googleChatConnector.listDocuments(
298+
TOKEN,
299+
{ ...config, maxMessages: '100' },
300+
undefined,
301+
{}
302+
)
303+
expect(
304+
classifyExternalDoc(
305+
changed.documents[0],
306+
existing,
307+
false,
308+
googleChatConnector.matchContentHash
309+
).type
310+
).toBe('update')
311+
})
312+
313+
it('rereads a lookback window when the provider omits creation timestamps', async () => {
314+
mode = 'unknown-time'
315+
vi.spyOn(Date, 'now').mockImplementation(() => now)
316+
const config = { lookbackDays: '90' }
317+
const initial = await read(config, {})
318+
if (!initial) throw new Error('Expected a space document')
319+
now += 60_000
320+
const page = await googleChatConnector.listDocuments(TOKEN, config, undefined, {})
321+
const existing = { id: 'stored', contentHash: initial.contentHash, storageKey: 'indexed' }
322+
expect(
323+
classifyExternalDoc(page.documents[0], existing, false, googleChatConnector.matchContentHash)
324+
.type
325+
).toBe('update')
326+
})
327+
328+
it('advances source metadata without replacing identical text but still indexes changed text', async () => {
329+
mode = 'window'
330+
vi.spyOn(Date, 'now').mockImplementation(() => now)
331+
const load = async () => {
332+
const context = {}
333+
await googleChatConnector.listDocuments(TOKEN, {}, undefined, context)
334+
const doc = await read({}, context)
335+
if (!doc) throw new Error('Expected a space document')
336+
return doc
337+
}
338+
const initial = await load()
339+
activity = '2026-10-02T12:00:00Z'
340+
const page = await googleChatConnector.listDocuments(TOKEN, {}, undefined, {})
341+
expect(
342+
classifyExternalDoc(
343+
page.documents[0],
344+
{ id: 'stored', contentHash: initial.contentHash, storageKey: 'indexed' },
345+
false,
346+
googleChatConnector.matchContentHash
347+
).type
348+
).toBe('update')
349+
const refreshed = await load()
350+
expect(
351+
contentHashMatch(
352+
refreshed.contentHash,
353+
initial.contentHash,
354+
googleChatConnector.matchContentHash
355+
)
356+
).toBe('equivalent')
357+
body = 'Changed decision: launch is approved.'
358+
const changed = await load()
359+
expect(
360+
contentHashMatch(
361+
changed.contentHash,
362+
refreshed.contentHash,
363+
googleChatConnector.matchContentHash
364+
)
365+
).toBe('stale')
366+
})
367+
368+
it.each(['100.0', '1e2'])(
369+
'keeps saved whole-number strings such as %s usable through validation and hydration',
370+
async (maxSpaces) => {
371+
vi.spyOn(Date, 'now').mockImplementation(() => now)
372+
const config = { maxSpaces, maxMessages: '3.00', lookbackDays: '90.0' }
373+
expect(await googleChatConnector.validateConfig(TOKEN, config)).toEqual({ valid: true })
374+
const context = {}
375+
await googleChatConnector.listDocuments(TOKEN, config, undefined, context)
376+
expect((await read(config, context))?.metadata?.messageCount).toBe(2)
377+
}
378+
)
379+
380+
it('reads the transcript without downloading unused card payloads', async () => {
381+
mode = 'projected'
382+
const doc = await read()
383+
expect(doc?.content).toMatch(/Older decision:[\s\S]*New decision:/)
196384
})
197385

198386
it.each([401, 403])('preserves HTTP %i authorization failures', async (status) => {
@@ -203,7 +391,17 @@ describe('Google Chat connector over real HTTP', () => {
203391
it.each(['maxMessages', 'maxSpaces', 'lookbackDays'])(
204392
'rejects invalid %s before contacting the provider',
205393
async (field) => {
206-
for (const value of ['Infinity', '0.5', '9007199254740992']) {
394+
for (const value of [
395+
'Infinity',
396+
'0.5',
397+
'1.0000000000000001',
398+
'0.99999999999999999',
399+
'1.0000000000000001e2',
400+
'1e-400',
401+
'9007199254740992',
402+
true,
403+
{},
404+
]) {
207405
const result = await googleChatConnector.validateConfig(TOKEN, { [field]: value })
208406
expect(result.valid, `${field}=${value}`).toBe(false)
209407
}

‎apps/sim/connectors/google-chat/google-chat.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ beforeEach(() => {
5353
if (url.includes('/spaces?')) {
5454
return jsonResponse({ spaces: listedSpaces, nextPageToken: listNextPageToken })
5555
}
56-
if (url.endsWith(`/${SPACE_NAME}`)) return jsonResponse(fetchedSpace)
56+
if (new URL(url).pathname.endsWith(`/${SPACE_NAME}`)) return jsonResponse(fetchedSpace)
5757
return jsonResponse({ error: { message: 'not found' } }, 404)
5858
})
5959
vi.stubGlobal('fetch', fetchMock)
@@ -94,11 +94,15 @@ describe('google-chat change detection', () => {
9494
const runTwo: Record<string, unknown> = {}
9595
const secondPage = await googleChatConnector.listDocuments('token', {}, undefined, runTwo)
9696

97-
// Stable within a run, so the hydrated document keeps the stub's hash …
9897
const hydrated = await googleChatConnector.getDocument('token', {}, SPACE_NAME, runOne)
99-
expect(hydrated?.contentHash).toBe(firstPage.documents[0].contentHash)
98+
if (!hydrated) throw new Error('Expected a space document')
99+
expect(
100+
googleChatConnector.matchContentHash?.(
101+
firstPage.documents[0].contentHash,
102+
hydrated.contentHash
103+
)
104+
).toBe('current')
100105

101-
// … and different on the next run, so content never silently goes stale.
102106
expect(secondPage.documents[0].contentHash).not.toBe(firstPage.documents[0].contentHash)
103107
})
104108
})

0 commit comments

Comments
 (0)