@startsimpli/integrations 0.1.3 → 0.1.5

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.
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./types": "./src/types/index.ts",
10
- "./api": "./src/api/index.ts"
10
+ "./api": "./src/api/index.ts",
11
+ "./corpus": "./src/corpus/index.ts",
12
+ "./corpus-llm": "./src/corpus/llm.ts"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -19,7 +21,8 @@
19
21
  "dependencies": {},
20
22
  "peerDependencies": {
21
23
  "react": ">=18.0.0",
22
- "@tanstack/react-query": ">=5.0.0"
24
+ "@tanstack/react-query": ">=5.0.0",
25
+ "@startsimpli/llm": ">=0.4.0"
23
26
  },
24
27
  "peerDependenciesMeta": {
25
28
  "react": {
@@ -27,6 +30,9 @@
27
30
  },
28
31
  "@tanstack/react-query": {
29
32
  "optional": true
33
+ },
34
+ "@startsimpli/llm": {
35
+ "optional": true
30
36
  }
31
37
  },
32
38
  "devDependencies": {
@@ -41,7 +47,8 @@
41
47
  "react": "^19.0.0",
42
48
  "react-dom": "^19.0.0",
43
49
  "typescript": "^6.0.0",
44
- "vitest": "^3.0.0"
50
+ "vitest": "^3.0.0",
51
+ "@startsimpli/llm": "0.4.14"
45
52
  },
46
53
  "scripts": {
47
54
  "type-check": "tsc --noEmit",
@@ -15,7 +15,7 @@
15
15
  * theme classNames.
16
16
  */
17
17
  import { describe, expect, it, vi } from 'vitest';
18
- import { render, screen, waitFor } from '@testing-library/react';
18
+ import { render, screen, waitFor, within } from '@testing-library/react';
19
19
  import userEvent from '@testing-library/user-event';
20
20
 
21
21
  import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
@@ -203,6 +203,7 @@ function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
203
203
  run_status: 'running', external_ref: null, output: {}, error: '',
204
204
  }),
205
205
  connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
206
+ updateConnection: vi.fn().mockResolvedValue(CONNECTED.results[0]),
206
207
  ...overrides,
207
208
  } as ConnectionsApi;
208
209
  }
@@ -268,6 +269,120 @@ describe('ConnectionsPage — injected, not contextual', () => {
268
269
  });
269
270
  });
270
271
 
272
+ // --------------------------------------------------------------------------
273
+ // the capability choice (bd startsim-768w.22.22)
274
+ // --------------------------------------------------------------------------
275
+
276
+ /**
277
+ * WHAT WENT WRONG ON PRODUCTION. An admin connected Granola on the `tandem`
278
+ * foundry through this very page. The card went green — `check()` really
279
+ * reached the vendor — and `capabilities_enabled` was stored as `[]`. With an
280
+ * empty list NO capability is enabled: not `poll`, not `action`, not
281
+ * `webhook_in`. So `poll_is_enabled()` answered false, no scheduled ingest leg
282
+ * was ever created, and the card sat there connected and completely inert.
283
+ *
284
+ * The server was not the liar. `POST /connections/` has accepted
285
+ * `capabilities_enabled[]` since the endpoint was designed, and
286
+ * `PATCH /connections/<id>/` has accepted edits to it and created the poll leg
287
+ * on the spot. THIS PAGE never sent either one — there was no control for it
288
+ * anywhere, at connect time or after — so the server received nothing, wrote
289
+ * nothing, and the admin had no way to say what they wanted.
290
+ *
291
+ * Both halves are asserted here: the choice is offered when connecting, and it
292
+ * stays changeable afterwards, because the connections that already exist
293
+ * cannot be fixed by anything the connect form does.
294
+ */
295
+ describe('capabilities are the admin’s choice, and the page has to ask', () => {
296
+ /** The capability picker inside ONE card. Both surfaces render one. */
297
+ function picker(scope: HTMLElement, which: 'catalog' | 'connection') {
298
+ const group = scope.querySelector<HTMLElement>(`[data-capabilities="${which}"]`);
299
+ if (!group) throw new Error(`no ${which} capability picker rendered`);
300
+ return within(group);
301
+ }
302
+
303
+ async function openConnectForm() {
304
+ const api = stubApi();
305
+ const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
306
+ // The catalog's available row. The unavailable one offers no button at all.
307
+ await userEvent.click(await screen.findByRole('button', { name: /^connect$/i }));
308
+ return { api, container };
309
+ }
310
+
311
+ it('offers every capability the connector declares, from the catalog row', async () => {
312
+ const { container } = await openConnectForm();
313
+ // The n8n fixture declares action + poll. Both are offered; nothing else is.
314
+ const boxes = picker(container, 'catalog').getAllByRole('checkbox');
315
+ expect(boxes.map((b) => b.getAttribute('value')).sort()).toEqual(['action', 'poll']);
316
+ for (const box of boxes) expect(box).toBeChecked();
317
+ });
318
+
319
+ it('SENDS the capability list on connect instead of leaving the card inert', async () => {
320
+ const { api } = await openConnectForm();
321
+ await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
322
+ await userEvent.click(screen.getByRole('button', { name: /test and save/i }));
323
+
324
+ await waitFor(() => expect(api.connect).toHaveBeenCalled());
325
+ const sent = (api.connect as ReturnType<typeof vi.fn>).mock.calls[0][1];
326
+ expect(sent.capabilities_enabled).toEqual(['action', 'poll']);
327
+ });
328
+
329
+ it('honours an admin who unchecks one before saving', async () => {
330
+ const { api, container } = await openConnectForm();
331
+ await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
332
+ await userEvent.click(picker(container, 'catalog').getByRole('checkbox', { name: /action/i }));
333
+ await userEvent.click(screen.getByRole('button', { name: /test and save/i }));
334
+
335
+ await waitFor(() => expect(api.connect).toHaveBeenCalled());
336
+ const sent = (api.connect as ReturnType<typeof vi.fn>).mock.calls[0][1];
337
+ expect(sent.capabilities_enabled).toEqual(['poll']);
338
+ });
339
+
340
+ it('will not let an admin save a connection that can do nothing', async () => {
341
+ const { container } = await openConnectForm();
342
+ await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
343
+ for (const box of picker(container, 'catalog').getAllByRole('checkbox')) {
344
+ await userEvent.click(box);
345
+ }
346
+ // The server refuses an empty list with a 400 (bd startsim-768w.22.22);
347
+ // refusing it here means the admin finds out before a round trip.
348
+ expect(screen.getByRole('button', { name: /test and save/i })).toBeDisabled();
349
+ });
350
+
351
+ it('lets an admin turn a capability ON for a connection that already exists', async () => {
352
+ // THE UNBLOCK. The `tandem` Granola card is already there with an empty
353
+ // list; nothing the connect form does can reach it. `Ops n8n` here has only
354
+ // `action` enabled, and the connector declares `poll` too.
355
+ const api = stubApi();
356
+ const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
357
+ await screen.findByText('Ops n8n');
358
+
359
+ const on = picker(container, 'connection');
360
+ const poll = on.getByRole('checkbox', { name: /poll/i });
361
+ expect(poll).not.toBeChecked();
362
+ await userEvent.click(poll);
363
+ await userEvent.click(on.getByRole('button', { name: /save/i }));
364
+
365
+ await waitFor(() => expect(api.updateConnection).toHaveBeenCalledWith('acme', 'c1', {
366
+ capabilities_enabled: ['action', 'poll'],
367
+ }));
368
+ });
369
+
370
+ it('shows the capability state read-only when manage is false', async () => {
371
+ const api = stubApi({
372
+ connections: vi.fn().mockResolvedValue({
373
+ ...CONNECTED,
374
+ permissions: { view: true, manage: false, issue_tokens: false },
375
+ }),
376
+ });
377
+ const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
378
+ await screen.findByText('Ops n8n');
379
+
380
+ const on = picker(container, 'connection');
381
+ expect(on.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
382
+ for (const box of on.getAllByRole('checkbox')) expect(box).toBeDisabled();
383
+ });
384
+ });
385
+
271
386
  describe('the catalog envelope, as the server actually ships it', () => {
272
387
  // REGRESSION. The first browser drive of this page died on
273
388
  // `(entry.credential_schema ?? []) is not iterable`, because these fixtures
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The Ask pipeline: citations, batching, and the honesty rules
3
+ * (bd startsim-768w.38.7).
4
+ *
5
+ * THE THING THIS SUITE PROTECTS: "an answer he cannot trace is an answer he
6
+ * cannot trust with a contract". So the assertions are not about the answer
7
+ * text — a model writes that — they are about whether the surface can ever
8
+ * present an untraceable answer as a traceable one. Every dishonest rendering
9
+ * has a test that forbids it.
10
+ *
11
+ * Scope isolation has its own file (`corpus-scope.test.ts`) because it is a
12
+ * different failure with a different blast radius.
13
+ */
14
+ import { describe, expect, it } from 'vitest';
15
+
16
+ import {
17
+ askCorpus,
18
+ buildAnswerRequest,
19
+ documentText,
20
+ renderAnswerPrompt,
21
+ resolveCitations,
22
+ } from '../corpus/ask';
23
+ import {
24
+ createFixtureCorpusApi,
25
+ createFixtureLlm,
26
+ FIXTURE_DOCUMENTS,
27
+ FIXTURE_ORG,
28
+ } from '../corpus/fixtures';
29
+ import { rankDocuments } from '../corpus/retrieval';
30
+ import type { AnswerDraft, AskRequest, CorpusLlm, RetrievedDocument } from '../corpus/types';
31
+
32
+ const SCOPE = { orgId: FIXTURE_ORG };
33
+ const ask = (question: string): AskRequest => ({ question, mode: 'ask', scope: SCOPE });
34
+
35
+ /** A model that returns exactly what the test tells it to. */
36
+ function scriptedLlm(draft: AnswerDraft, calls: unknown[] = []): CorpusLlm {
37
+ return {
38
+ describe: () => ({ provider: 'scripted', model: 'scripted-1', live: false }),
39
+ async answer(input) {
40
+ calls.push(input);
41
+ return draft;
42
+ },
43
+ };
44
+ }
45
+
46
+ function retrievedFor(question: string): RetrievedDocument[] {
47
+ const own = FIXTURE_DOCUMENTS.filter((document) => document.scope.orgId === FIXTURE_ORG);
48
+ return rankDocuments(own, question, { limit: 5 });
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe('batching — N documents, ONE call', () => {
54
+ it('sends every retrieved document in a single request', async () => {
55
+ const calls: unknown[] = [];
56
+ const corpus = createFixtureCorpusApi({ scope: SCOPE });
57
+ const llm = scriptedLlm({ answer: 'ok', citations: [] }, calls);
58
+
59
+ await askCorpus(ask('Acme renewal rate and seats'), { corpus, llm });
60
+
61
+ expect(calls).toHaveLength(1);
62
+ const input = calls[0] as { documents: unknown[] };
63
+ expect(input.documents.length).toBeGreaterThan(1);
64
+ });
65
+
66
+ it('gives the model an id per document and repeats it in the prompt', () => {
67
+ const retrieved = retrievedFor('Acme renewal');
68
+ const request = buildAnswerRequest(ask('Acme renewal'), retrieved, 4000);
69
+ const prompt = renderAnswerPrompt(request);
70
+
71
+ for (const document of request.documents) {
72
+ // The echoed id is the whole citation mechanism. Without it in the prompt
73
+ // there is nothing the model could return that resolves to a record.
74
+ expect(prompt).toContain(`<document id="${document.id}">`);
75
+ }
76
+ expect(request.documents.map((d) => d.id)).toEqual(retrieved.map((r) => r.document.id));
77
+ });
78
+
79
+ it('carries the SOURCE into the prompt, so the model can say where it learned it', () => {
80
+ const retrieved = retrievedFor('Acme renewal contract');
81
+ const prompt = renderAnswerPrompt(buildAnswerRequest(ask('Acme renewal contract'), retrieved, 4000));
82
+ expect(prompt).toMatch(/source: (Granola|Dropbox|Google Drive|Stripe)/);
83
+ });
84
+
85
+ it('truncates a long document rather than dropping it from the batch', () => {
86
+ const long = {
87
+ ...FIXTURE_DOCUMENTS[0],
88
+ sections: [{ key: 'transcript', label: 'Transcript', text: 'x'.repeat(50_000) }],
89
+ };
90
+ const text = documentText(long, 500);
91
+ expect(text.length).toBeLessThan(700);
92
+ expect(text).toContain('[truncated]');
93
+ });
94
+ });
95
+
96
+ describe('citations resolve to records retrieval actually supplied', () => {
97
+ it('resolves a good citation to the record itself', () => {
98
+ const retrieved = retrievedFor('Acme renewal');
99
+ const id = retrieved[0].document.id;
100
+ const { citations, unresolved } = resolveCitations(
101
+ { citations: [{ documentId: id, quote: 'twelve months' }] },
102
+ retrieved,
103
+ );
104
+ expect(unresolved).toEqual([]);
105
+ expect(citations).toHaveLength(1);
106
+ expect(citations[0].document.id).toBe(id);
107
+ expect(citations[0].quote).toBe('twelve months');
108
+ });
109
+
110
+ it('drops an invented id and REPORTS it', () => {
111
+ const retrieved = retrievedFor('Acme renewal');
112
+ const { citations, unresolved } = resolveCitations(
113
+ { citations: [{ documentId: 'not_this_never_existed' }] },
114
+ retrieved,
115
+ );
116
+ expect(citations).toHaveLength(0);
117
+ // Swallowing this would render a clean answer over a hallucinated source.
118
+ expect(unresolved).toEqual(['not_this_never_existed']);
119
+ });
120
+
121
+ it('deduplicates a repeated citation', () => {
122
+ const retrieved = retrievedFor('Acme renewal');
123
+ const id = retrieved[0].document.id;
124
+ const { citations } = resolveCitations(
125
+ { citations: [{ documentId: id }, { documentId: id }] },
126
+ retrieved,
127
+ );
128
+ expect(citations).toHaveLength(1);
129
+ });
130
+
131
+ it('substitutes the record’s own text when the model returns no quote', () => {
132
+ const retrieved = retrievedFor('Acme renewal');
133
+ const { citations } = resolveCitations(
134
+ { citations: [{ documentId: retrieved[0].document.id }] },
135
+ retrieved,
136
+ );
137
+ expect(citations[0].quote.length).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('accepts the snake_case spelling a model may emit', () => {
141
+ const retrieved = retrievedFor('Acme renewal');
142
+ const id = retrieved[0].document.id;
143
+ const { citations } = resolveCitations(
144
+ { citations: [{ document_id: id } as unknown as { documentId: string }] },
145
+ retrieved,
146
+ );
147
+ expect(citations).toHaveLength(1);
148
+ });
149
+ });
150
+
151
+ describe('the surface can never present an untraceable answer as traceable', () => {
152
+ const corpus = () => createFixtureCorpusApi({ scope: SCOPE });
153
+
154
+ it('marks a cited answer as answered', async () => {
155
+ const result = await askCorpus(ask('Acme renewal rate'), {
156
+ corpus: corpus(),
157
+ llm: createFixtureLlm(),
158
+ });
159
+ expect(result.status).toBe('answered');
160
+ expect(result.citations.length).toBeGreaterThan(0);
161
+ });
162
+
163
+ it('marks an answer with NO resolvable citation as uncited, not answered', async () => {
164
+ const result = await askCorpus(ask('Acme renewal rate'), {
165
+ corpus: corpus(),
166
+ llm: scriptedLlm({ answer: 'You agreed four thousand a month.', citations: [] }),
167
+ });
168
+ // The text is still returned — hiding it would be its own kind of lying —
169
+ // but the status is what the UI keys its warning off.
170
+ expect(result.status).toBe('uncited');
171
+ expect(result.answer).toContain('four thousand');
172
+ expect(result.citations).toHaveLength(0);
173
+ });
174
+
175
+ it('marks an answer whose every citation was invented as uncited, and names them', async () => {
176
+ const result = await askCorpus(ask('Acme renewal rate'), {
177
+ corpus: corpus(),
178
+ llm: scriptedLlm({
179
+ answer: 'You agreed four thousand a month.',
180
+ citations: [{ documentId: 'doc_that_does_not_exist' }],
181
+ }),
182
+ });
183
+ expect(result.status).toBe('uncited');
184
+ expect(result.unresolvedCitations).toEqual(['doc_that_does_not_exist']);
185
+ });
186
+
187
+ it('reports no_evidence when the corpus has nothing, without calling the model', async () => {
188
+ const calls: unknown[] = [];
189
+ const result = await askCorpus(ask('zzzznothingmatchesthis'), {
190
+ corpus: corpus(),
191
+ llm: scriptedLlm({ answer: 'I could make something up.', citations: [] }, calls),
192
+ });
193
+ expect(result.status).toBe('no_evidence');
194
+ // Not calling the model at all is the only way to guarantee it cannot
195
+ // improvise an answer over an empty document list.
196
+ expect(calls).toHaveLength(0);
197
+ });
198
+
199
+ it('names the provider that actually answered, and whether it was live', async () => {
200
+ const result = await askCorpus(ask('Acme renewal'), {
201
+ corpus: corpus(),
202
+ llm: createFixtureLlm(),
203
+ });
204
+ expect(result.provider.live).toBe(false);
205
+ expect(result.provider.provider).toBe('fixture');
206
+ });
207
+
208
+ it('carries freshness onto the answer so a stale corpus is visible', async () => {
209
+ const result = await askCorpus(ask('Acme renewal'), {
210
+ corpus: corpus(),
211
+ llm: createFixtureLlm(),
212
+ });
213
+ expect(result.freshness?.some((source) => source.status !== 'ok')).toBe(true);
214
+ });
215
+ });
216
+
217
+ describe('retrieval spans documents and money, and stays generic', () => {
218
+ it('retrieves a Stripe money event alongside meeting notes', async () => {
219
+ const calls: unknown[] = [];
220
+ await askCorpus(ask('Acme Robotics charge refunded'), {
221
+ corpus: createFixtureCorpusApi({ scope: SCOPE }),
222
+ llm: scriptedLlm({ answer: 'ok', citations: [] }, calls),
223
+ });
224
+ const ids = (calls[0] as { documents: Array<{ id: string }> }).documents.map((d) => d.id);
225
+ // "What did we agree with this company" spans a meeting note and an invoice,
226
+ // and it cannot span them if they are ranked in separate universes.
227
+ expect(ids).toContain('ch_RedSuiteJpy0001');
228
+ });
229
+
230
+ it('still answers when the tenant has NO money connector at all', async () => {
231
+ const corpus = createFixtureCorpusApi({ scope: SCOPE });
232
+ delete (corpus as { listMoneyEvents?: unknown }).listMoneyEvents;
233
+
234
+ const result = await askCorpus(ask('Acme renewal rate'), { corpus, llm: createFixtureLlm() });
235
+ expect(result.status).toBe('answered');
236
+ });
237
+
238
+ it('answers a Dropbox-only question with a Dropbox citation', async () => {
239
+ // The standing generic test: nothing in the pipeline may be better at
240
+ // meeting notes than at files.
241
+ const result = await askCorpus(ask('master services agreement seats cap'), {
242
+ corpus: createFixtureCorpusApi({ scope: SCOPE }),
243
+ llm: createFixtureLlm(),
244
+ });
245
+ expect(result.citations.some((citation) => citation.document.source === 'dropbox')).toBe(true);
246
+ });
247
+
248
+ it('survives a money endpoint that fails, rather than failing the whole answer', async () => {
249
+ const corpus = createFixtureCorpusApi({ scope: SCOPE });
250
+ corpus.listMoneyEvents = async () => { throw new Error('money service is down'); };
251
+
252
+ const result = await askCorpus(ask('Acme renewal rate'), { corpus, llm: createFixtureLlm() });
253
+ expect(result.status).toBe('answered');
254
+ });
255
+ });
256
+
257
+ describe('generate mode', () => {
258
+ it('asks the model for a draft and still requires citations', async () => {
259
+ const calls: unknown[] = [];
260
+ const result = await askCorpus(
261
+ {
262
+ question: 'Draft a renewal letter for Acme Robotics',
263
+ mode: 'generate',
264
+ scope: SCOPE,
265
+ instructions: 'Formal, one page',
266
+ },
267
+ { corpus: createFixtureCorpusApi({ scope: SCOPE }), llm: scriptedLlm({
268
+ answer: 'Dear Acme…',
269
+ citations: [{ documentId: 'dbx_9812f0', quote: 'twelve (12) months' }],
270
+ }, calls) },
271
+ );
272
+
273
+ const input = calls[0] as { mode: string; instructions?: string };
274
+ expect(input.mode).toBe('generate');
275
+ expect(input.instructions).toBe('Formal, one page');
276
+ expect(result.status).toBe('answered');
277
+ expect(result.citations[0].document.source).toBe('dropbox');
278
+ });
279
+ });