11/**
22 * Real sync workers, PostgreSQL, file storage, indexing and reconciliation for Confluence image
33 * attachments. The source listing is a synthetic connector behind the real Confluence meta, and
4- * the vision extraction and embeddings are synthetic, so no request leaves the process.
4+ * the vision extraction and embeddings are synthetic, so no request leaves the process. A
5+ * `cutOff` image runs the real extractor against a Gemini reply stopped at its token limit.
56 */
67import { mkdtempSync } from 'node:fs'
78import { rm } from 'node:fs/promises'
@@ -13,10 +14,12 @@ import {
1314 embedding ,
1415 knowledgeConnector ,
1516 organization ,
17+ usageLog ,
1618 user ,
1719 workspace ,
1820} from '@sim/db/schema'
1921import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
22+ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
2023import { and , eq , inArray , isNull } from 'drizzle-orm'
2124import { afterAll , beforeAll , beforeEach , describe , expect , it , vi } from 'vitest'
2225
@@ -27,6 +30,7 @@ const fixture = vi.hoisted(() => {
2730 /** externalId to version; a version bump changes the image's bytes and hash. */
2831 images : new Map < string , number > ( ) ,
2932 decorative : new Set < string > ( ) ,
33+ cutOff : new Set < string > ( ) ,
3034 extractions : [ ] as string [ ] ,
3135 /** Each chunk gets its own one-hot vector, so the shared HNSW graph sees no duplicates. */
3236 nextVector : 0 ,
@@ -53,22 +57,49 @@ vi.mock('@/lib/embeddings', async () => ({
5357 pricingId : 'text-embedding-3-small' ,
5458 } ) ,
5559} ) )
56- vi . mock ( '@/lib/knowledge/documents/image-extraction' , async ( importOriginal ) => ( {
57- ...( await importOriginal < typeof import ( '@/lib/knowledge/documents/image-extraction' ) > ( ) ) ,
58- extractImageText : async ( { filename } : { filename : string } ) => {
59- fixture . extractions . push ( filename )
60- const externalId = filename . replace ( / \. p n g $ / , '' )
61- if ( fixture . decorative . has ( externalId ) ) return { decorative : true , renderedText : '' }
62- return {
63- decorative : false ,
64- renderedText : `${ externalId } version ${ fixture . images . get ( externalId ) } : Region EMEA launches in Q1.` ,
65- usage : { model : 'gemini-3.8-flash' , cost : 0 , inputTokens : 10 , outputTokens : 10 } ,
66- }
67- } ,
60+ vi . mock ( '@/lib/knowledge/documents/image-extraction-gemini' , async ( importOriginal ) => ( {
61+ ...( await importOriginal < typeof import ( '@/lib/knowledge/documents/image-extraction-gemini' ) > ( ) ) ,
62+ requestGeminiImageExtraction : async ( ) => ( {
63+ text : '{"kind": "table", "rendered_text": "Region: EMEA' ,
64+ finishReason : 'MAX_TOKENS' ,
65+ inputTokens : 1200 ,
66+ outputTokens : 32_768 ,
67+ } ) ,
6868} ) )
69+ vi . mock ( '@/lib/knowledge/documents/image-extraction' , async ( importOriginal ) => {
70+ const actual = await importOriginal < typeof import ( '@/lib/knowledge/documents/image-extraction' ) > ( )
71+ return {
72+ ...actual ,
73+ extractImageText : async ( input : Parameters < typeof actual . extractImageText > [ 0 ] ) => {
74+ const externalId = input . filename . replace ( / \. p n g $ / , '' )
75+ if ( fixture . cutOff . has ( externalId ) ) return actual . extractImageText ( input )
76+ fixture . extractions . push ( input . filename )
77+ if ( fixture . decorative . has ( externalId ) ) return { decorative : true , renderedText : '' }
78+ return {
79+ decorative : false ,
80+ renderedText : `${ externalId } version ${ fixture . images . get ( externalId ) } : Region EMEA launches in Q1.` ,
81+ }
82+ } ,
83+ }
84+ } )
6985vi . mock ( '@/connectors/registry.server' , async ( ) => {
7086 const { confluenceConnectorMeta } = await import ( '@/connectors/confluence/meta' )
7187 const enabled = ( config : Record < string , unknown > ) => config . imageAttachments === 'semantic'
88+ /** A real, noisy PNG over the semantic byte floor, so the real extractor reads it. */
89+ const cutOffImage = async ( ) => {
90+ const sharp = ( await import ( 'sharp' ) ) . default
91+ return sharp ( {
92+ create : {
93+ width : 256 ,
94+ height : 256 ,
95+ channels : 3 ,
96+ background : '#808080' ,
97+ noise : { type : 'gaussian' , mean : 128 , sigma : 60 } ,
98+ } ,
99+ } )
100+ . png ( )
101+ . toBuffer ( )
102+ }
72103 const stub = ( externalId : string ) => {
73104 const version = fixture . images . get ( externalId ) ?? 1
74105 return {
@@ -95,7 +126,9 @@ vi.mock('@/connectors/registry.server', async () => {
95126 externalId : string
96127 ) => {
97128 if ( ! fixture . images . has ( externalId ) ) return null
98- const bytes = Buffer . from ( `fixture png ${ externalId } v${ fixture . images . get ( externalId ) } ` )
129+ const bytes = fixture . cutOff . has ( externalId )
130+ ? await cutOffImage ( )
131+ : Buffer . from ( `fixture png ${ externalId } v${ fixture . images . get ( externalId ) } ` )
99132 return {
100133 ...stub ( externalId ) ,
101134 contentDeferred : false ,
@@ -121,9 +154,11 @@ import {
121154import * as connectorTokens from '@/lib/knowledge/connectors/access-token'
122155import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
123156import { IMAGES_DEFERRED_NOTICE } from '@/lib/knowledge/connectors/sync-limits'
157+ import { resolveImageExtractionModel } from '@/lib/knowledge/documents/image-extraction-gemini'
124158
125159describe ( 'Confluence image attachments through sync, indexing and reconciliation' , ( ) => {
126160 const ids = createKnowledgeAclFixtureIds ( )
161+ const principal = createSessionPrincipal ( { userId : ids . aliceId , sessionId : 'confluence-images' } )
127162 let billing : Awaited < ReturnType < typeof resolveBillingAttribution > >
128163 const priorGeminiKeys = {
129164 GEMINI_API_KEY : env . GEMINI_API_KEY ,
@@ -170,7 +205,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
170205 /** Changes the setting the way a user does; the update dispatches the next sync, which runs in-process. */
171206 async function setImageAttachments ( imageAttachments : 'semantic' | 'off' ) {
172207 await updateKnowledgeConnector . execute ( {
173- principal : { kind : 'session' , userId : ids . aliceId , sessionId : 'confluence-images' } ,
208+ principal,
174209 input : {
175210 connectorId : ids . connectorId ,
176211 updates : {
@@ -182,7 +217,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
182217 }
183218 function setExcluded ( documentId : string , operation : 'exclude' | 'restore' ) {
184219 return updateKnowledgeConnectorDocuments . execute ( {
185- principal : { kind : 'session' , userId : ids . aliceId , sessionId : 'confluence-images' } ,
220+ principal,
186221 input : {
187222 knowledgeBaseId : ids . knowledgeBaseId ,
188223 connectorId : ids . connectorId ,
@@ -244,6 +279,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
244279 Object . assign ( env , { GEMINI_API_KEY : 'fixture-gemini-key' } )
245280 fixture . extractions . length = 0
246281 fixture . decorative . clear ( )
282+ fixture . cutOff . clear ( )
247283 fixture . images . clear ( )
248284 await db . delete ( document ) . where ( eq ( document . connectorId , ids . connectorId ) )
249285 await configure ( 'semantic' )
@@ -335,6 +371,32 @@ describe('Confluence image attachments through sync, indexing and reconciliation
335371 expect ( await setExcluded ( excluded . id , 'restore' ) ) . toMatchObject ( { count : 1 } )
336372 } , 120_000 )
337373
374+ it ( 'bills the Gemini call once when a reply cut off at its token limit fails the image' , async ( ) => {
375+ fixture . images . set ( 'image-a' , 1 )
376+ fixture . cutOff . add ( 'image-a' )
377+ await executeSync ( ids . connectorId , { fullSync : true , billingAttribution : billing } )
378+
379+ await expect
380+ . poll ( async ( ) => ( await images ( ) ) . map ( ( row ) => row . processingStatus ) , { timeout : 45_000 } )
381+ . toEqual ( [ 'failed' ] )
382+ const [ failed ] = await images ( )
383+ expect ( failed . processingError ) . toContain ( 'more text than one extraction can return' )
384+ expect ( await vectors ( [ failed . id ] ) ) . toEqual ( [ ] )
385+ const charges = await db
386+ . select ( )
387+ . from ( usageLog )
388+ . where (
389+ and (
390+ eq ( usageLog . workspaceId , ids . workspaceId ) ,
391+ eq ( usageLog . source , 'knowledge-base' ) ,
392+ eq ( usageLog . description , resolveImageExtractionModel ( ) )
393+ )
394+ )
395+ expect ( charges ) . toHaveLength ( 1 )
396+ expect ( Number ( charges [ 0 ] . cost ) ) . toBeGreaterThan ( 0 )
397+ expect ( charges [ 0 ] . metadata ) . toEqual ( { inputTokens : 1200 , outputTokens : 32_768 } )
398+ } , 120_000 )
399+
338400 it ( 'keeps images off for a connector created before the setting existed' , async ( ) => {
339401 fixture . images . set ( 'image-a' , 1 )
340402 await configure ( undefined )
@@ -378,7 +440,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation
378440 'saves a new connector with Image Attachments on unless the request chose otherwise (%o)' ,
379441 async ( chosen , saved ) => {
380442 const created = await createKnowledgeConnector . execute ( {
381- principal : { kind : 'session' , userId : ids . aliceId , sessionId : 'confluence-images' } ,
443+ principal,
382444 input : {
383445 knowledgeBaseId : ids . knowledgeBaseId ,
384446 connectorType : 'confluence' ,
0 commit comments