@startsimpli/integrations 0.2.14 → 0.2.17

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.14",
3
+ "version": "0.2.17",
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",
@@ -48,7 +48,7 @@
48
48
  "react-dom": "^19.0.0",
49
49
  "typescript": "^6.0.0",
50
50
  "vitest": "^3.0.0",
51
- "@startsimpli/llm": "0.4.15"
51
+ "@startsimpli/llm": "0.4.16"
52
52
  },
53
53
  "scripts": {
54
54
  "type-check": "tsc --noEmit",
@@ -0,0 +1,246 @@
1
+ /**
2
+ * THE FIELD READ GATE, from the client's side (bd startsim-768w.38.33).
3
+ *
4
+ * `corpus-scope.test.ts` next door asks "can the pipeline be made to read
5
+ * another WORKSPACE's records". This asks the narrower and, on the tandem
6
+ * tenant, more likely question: can it be made to read a part of its OWN
7
+ * workspace's record that the server withheld from this reader.
8
+ *
9
+ * SAME ADVERSARIAL POSTURE, DELIBERATELY. Every case runs against a tenant that
10
+ * is LEAKING — it names `body` in `redacted_fields` and then sends `body`
11
+ * anyway, which is what a half-rolled deploy, a response cached from an admin's
12
+ * session, or a serializer edited without its gate looks like from out here. If
13
+ * the pipeline is only safe because the server is, these fail. That is the
14
+ * point; a test against a correctly-behaving server proves the server, not the
15
+ * client.
16
+ *
17
+ * THE BAIT is `doc_checkin`: a document whose SUMMARY is anodyne and whose
18
+ * withheld `body` contains the verbatim sentence that answers the question best.
19
+ * A mapper that reads `data.body` without checking `redacted_fields` puts that
20
+ * sentence into the prompt, the model quotes it, and the leak arrives in the
21
+ * most convincing possible format — cited, fluent, and attributed to the user's
22
+ * own corpus.
23
+ */
24
+ import { describe, expect, it } from 'vitest';
25
+
26
+ import { askCorpus } from '../corpus/ask';
27
+ import { createCorpusApi } from '../corpus/client';
28
+ import { toSourceDocument, withheldFieldsOf } from '../corpus/entities';
29
+ import type { TenantEntity } from '../corpus/entities';
30
+ import { createFixtureCorpusHttp, createFixtureLlm, FIXTURE_ORG } from '../corpus/fixtures';
31
+ import type { CorpusHttp, CorpusLlm, CorpusScope } from '../corpus/types';
32
+
33
+ const SCOPE: CorpusScope = { orgId: FIXTURE_ORG };
34
+
35
+ /** The sentence nobody consented to re-host in a third system. */
36
+ const VERBATIM = 'Dana: I relapsed in March and I have been in therapy weekly since.';
37
+ const ANODYNE = 'Personal check-in. Agreed to revisit the lease in Q3.';
38
+
39
+ /**
40
+ * A tenant row as the LEAKING server sends it: the gate ran (it named what it
41
+ * withheld) and the payload came anyway.
42
+ */
43
+ function leakingEntity(overrides: Partial<TenantEntity> = {}): TenantEntity {
44
+ return {
45
+ id: 'doc_checkin',
46
+ entity_type: 'source_document',
47
+ external_id: 'granola:not_checkin',
48
+ name: 'Personal check-in — therapy, substance use, and relationship progress',
49
+ org_id: FIXTURE_ORG,
50
+ created_at: '2026-08-01T10:00:00Z',
51
+ data: {
52
+ source: 'granola',
53
+ subtype: 'meeting',
54
+ title: 'Personal check-in',
55
+ summary: ANODYNE,
56
+ body: VERBATIM,
57
+ occurred_at: '2026-08-01T09:00:00Z',
58
+ },
59
+ redacted_fields: ['body'],
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ /** The same row as a CORRECT server sends it: stripped, and saying so. */
65
+ function gatedEntity(): TenantEntity {
66
+ const row = leakingEntity();
67
+ const { body: _dropped, ...rest } = row.data as Record<string, unknown>;
68
+ return { ...row, data: rest };
69
+ }
70
+
71
+ function httpFor(entities: TenantEntity[]): CorpusHttp {
72
+ return createFixtureCorpusHttp({ entities, relationships: [] }).http as CorpusHttp;
73
+ }
74
+
75
+ function apiFor(entities: TenantEntity[]) {
76
+ return createCorpusApi({ http: httpFor(entities), scope: SCOPE, supportedFilters: [] });
77
+ }
78
+
79
+ describe('the mapper honours the server’s own declaration', () => {
80
+ it('drops a withheld key even when the server sent it anyway', () => {
81
+ const document = toSourceDocument(leakingEntity(), SCOPE);
82
+ expect(document.body).not.toContain('relapsed');
83
+ expect(JSON.stringify(document)).not.toContain('relapsed');
84
+ });
85
+
86
+ it('falls back to the summary rather than rendering an empty record', () => {
87
+ // A blank document would be the other failure: safe, and useless. The
88
+ // meeting is still a meeting, and its summary was never restricted.
89
+ const document = toSourceDocument(leakingEntity(), SCOPE);
90
+ expect(document.body).toBe(ANODYNE);
91
+ expect(document.summary).toBe(ANODYNE);
92
+ });
93
+
94
+ it('says WHICH field was withheld, so absence is not mistaken for emptiness', () => {
95
+ // The distinction the whole gate turns on: "this meeting has no transcript"
96
+ // and "you were not shown this meeting's transcript" are different facts,
97
+ // and only one of them is an answer.
98
+ expect(toSourceDocument(leakingEntity(), SCOPE).withheldFields).toEqual(['body']);
99
+ expect(withheldFieldsOf(leakingEntity())).toEqual(['body']);
100
+ });
101
+
102
+ it('accepts the camelCase spelling the api client produces', () => {
103
+ const row = leakingEntity();
104
+ const camel: TenantEntity = { ...row, redacted_fields: undefined, redactedFields: ['body'] };
105
+ expect(toSourceDocument(camel, SCOPE).body).not.toContain('relapsed');
106
+ });
107
+
108
+ it('leaves a record with nothing withheld exactly as it was', () => {
109
+ // The migration property, from this side: a corpus with no policy anywhere
110
+ // must map identically to how it mapped before this existed.
111
+ const open = leakingEntity({ redacted_fields: undefined });
112
+ const document = toSourceDocument(open, SCOPE);
113
+ expect(document.body).toBe(VERBATIM);
114
+ expect(document.withheldFields).toBeUndefined();
115
+ });
116
+
117
+ it('withholds a section as readily as a top-level field', () => {
118
+ const row = leakingEntity({
119
+ data: {
120
+ source: 'granola',
121
+ summary: ANODYNE,
122
+ transcript: VERBATIM,
123
+ },
124
+ redacted_fields: ['transcript'],
125
+ });
126
+ const document = toSourceDocument(row, SCOPE);
127
+ expect(JSON.stringify(document.sections)).not.toContain('relapsed');
128
+ });
129
+ });
130
+
131
+ describe('the corpus client never surfaces a withheld field', () => {
132
+ it('keeps it out of a listed document', async () => {
133
+ const page = await apiFor([leakingEntity()]).listDocuments({ page: 1, pageSize: 50 });
134
+ expect(page.results).toHaveLength(1);
135
+ expect(JSON.stringify(page.results)).not.toContain('relapsed');
136
+ expect(page.results[0]?.withheldFields).toEqual(['body']);
137
+ });
138
+
139
+ it('keeps it out of a fetched document', async () => {
140
+ const document = await apiFor([leakingEntity()]).getDocument('doc_checkin');
141
+ expect(JSON.stringify(document)).not.toContain('relapsed');
142
+ });
143
+ });
144
+
145
+ describe('askCorpus — the withheld text never reaches the model', () => {
146
+ /** A model that records exactly what it was shown. */
147
+ function spyLlm(): { llm: CorpusLlm; seen: string[] } {
148
+ const seen: string[] = [];
149
+ const inner = createFixtureLlm();
150
+ return {
151
+ seen,
152
+ llm: {
153
+ describe: inner.describe,
154
+ async answer(input) {
155
+ seen.push(JSON.stringify(input.documents));
156
+ return inner.answer(input);
157
+ },
158
+ },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * A question answerable from the SUMMARY, so the document really is retrieved
164
+ * and really does reach the prompt. Asking something only the withheld text
165
+ * could answer would prove nothing: an empty prompt keeps every secret.
166
+ */
167
+ const question = 'What did we agree about the lease?';
168
+
169
+ it('puts the document in the prompt WITHOUT the withheld text', async () => {
170
+ const { llm, seen } = spyLlm();
171
+ await askCorpus({ question, mode: 'ask', scope: SCOPE }, { corpus: apiFor([leakingEntity()]), llm });
172
+
173
+ expect(seen).toHaveLength(1); // ONE call, not one per document
174
+ expect(seen[0]).toContain('lease'); // the record DID reach the model...
175
+ expect(seen[0]).not.toContain('relapsed'); // ...without the part it may not read
176
+ });
177
+
178
+ it('never quotes it back in the answer or a citation', async () => {
179
+ const result = await askCorpus(
180
+ { question, mode: 'ask', scope: SCOPE },
181
+ { corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
182
+ );
183
+
184
+ expect(result.status).toBe('answered');
185
+ expect(result.answer).not.toContain('relapsed');
186
+ expect(JSON.stringify(result.citations)).not.toContain('relapsed');
187
+ });
188
+
189
+ it('reports that something was withheld rather than absorbing it', async () => {
190
+ // Same contract as `droppedOutOfScope`: an answer built from a partial
191
+ // record has to say so. Otherwise "your corpus has nothing about that" and
192
+ // "you were not shown the part that does" are the same sentence.
193
+ const result = await askCorpus(
194
+ { question, mode: 'ask', scope: SCOPE },
195
+ { corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
196
+ );
197
+ expect(result.withheldFields).toEqual(['body']);
198
+ });
199
+
200
+ it('tells the MODEL the record is partly withheld, so it cannot answer a confident no', async () => {
201
+ const { llm, seen } = spyLlm();
202
+ await askCorpus({ question, mode: 'ask', scope: SCOPE }, { corpus: apiFor([leakingEntity()]), llm });
203
+ expect(seen[0]).toContain('withheldFields');
204
+ });
205
+
206
+ it('does not retrieve on a term that exists ONLY in the withheld text', async () => {
207
+ // The client-side half of the search oracle the tenant API closes. If the
208
+ // withheld words were still rankable here, a reader could confirm what a
209
+ // transcript contains by watching which records come back — an answer about
210
+ // somebody's private speech, delivered without ever printing it.
211
+ const result = await askCorpus(
212
+ { question: 'relapsed', mode: 'ask', scope: SCOPE },
213
+ { corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
214
+ );
215
+ expect(result.status).toBe('no_evidence');
216
+ expect(result.citations).toHaveLength(0);
217
+ expect(result.answer).not.toContain('relapsed');
218
+ });
219
+
220
+ it('answers identically whether the server leaked or behaved', async () => {
221
+ // The falsifiable version of "the client does not depend on the server
222
+ // getting it right": same question, one API stripping the field and one
223
+ // not, and the two answers must not differ.
224
+ const ask = (entity: TenantEntity) =>
225
+ askCorpus(
226
+ { question, mode: 'ask', scope: SCOPE },
227
+ { corpus: apiFor([entity]), llm: createFixtureLlm() },
228
+ );
229
+ const [leaked, gated] = await Promise.all([ask(leakingEntity()), ask(gatedEntity())]);
230
+ expect(leaked.answer).toBe(gated.answer);
231
+ expect(leaked.withheldFields).toEqual(gated.withheldFields);
232
+ });
233
+
234
+ it('still answers from the parts the reader IS allowed to see', async () => {
235
+ // Over-blocking would be the other way to fail: the summary was never
236
+ // restricted, and a corpus that goes silent because one field is gated has
237
+ // taken the product away in order to protect it.
238
+ const result = await askCorpus(
239
+ { question, mode: 'ask', scope: SCOPE },
240
+ { corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
241
+ );
242
+ expect(result.status).toBe('answered');
243
+ expect(result.citations.length).toBeGreaterThan(0);
244
+ expect(result.answer).toContain('lease');
245
+ });
246
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * THE SECOND SIXTY-SECOND WALL (bd startsim-768w.38.28).
3
+ *
4
+ * The one everybody measured was the tenant gateway's. This is the other one:
5
+ * the provider client in `@startsimpli/llm` defaults to a 60,000ms
6
+ * AbortController, and against claude-sonnet-5 over the real tandem corpus one
7
+ * document takes 19.6 seconds while two (7,909 prompt characters) do not finish
8
+ * at all. Because the extract call is deliberately wrapped so a provider outage
9
+ * costs the extras and not the list, hitting it was SILENT — the run returned
10
+ * every rules-derived commitment and `fromModel: 0`, which reads exactly like
11
+ * "the model found nothing to add".
12
+ *
13
+ * Putting extraction on a job is what makes a longer deadline affordable:
14
+ * nothing is holding a socket open waiting for it. So the deadline has to be
15
+ * settable per call, and it has to actually reach the provider — an option that
16
+ * is accepted and dropped is worse than no option, because it looks fixed.
17
+ */
18
+ import { describe, expect, it, vi } from 'vitest';
19
+
20
+ import { createCorpusExtractor, createCorpusLlm } from '../corpus/llm';
21
+
22
+ function fakeService(content: string) {
23
+ return {
24
+ chat: vi.fn(async () => ({ content, model: 'claude-sonnet-5' })),
25
+ isAvailable: () => true,
26
+ getAvailableProviders: () => ['anthropic'],
27
+ };
28
+ }
29
+
30
+ describe('a queued run can buy real time from the provider', () => {
31
+ it('passes the deadline the caller asked for through to the provider call', async () => {
32
+ const service = fakeService('{"items":[],"resolutions":[]}');
33
+ const extractor = createCorpusExtractor({
34
+ service: service as never,
35
+ timeoutMs: 240_000,
36
+ env: {},
37
+ });
38
+
39
+ await extractor.extract({ documents: [] });
40
+
41
+ expect(service.chat).toHaveBeenCalledTimes(1);
42
+ expect(service.chat.mock.calls[0][2]).toEqual({ timeout: 240_000 });
43
+ });
44
+
45
+ it('carries the deadline into the JSON-repair retry too', async () => {
46
+ // The repair is a correction of the SAME request, so it has the same amount
47
+ // of work to do. Sending it with the default deadline would make the retry
48
+ // fail for a reason the first attempt did not.
49
+ const service = fakeService('not json at all');
50
+ const extractor = createCorpusExtractor({
51
+ service: service as never,
52
+ timeoutMs: 240_000,
53
+ env: {},
54
+ });
55
+
56
+ await extractor.extract({ documents: [] });
57
+
58
+ expect(service.chat).toHaveBeenCalledTimes(2);
59
+ expect(service.chat.mock.calls[1][2]).toEqual({ timeout: 240_000 });
60
+ });
61
+
62
+ it('falls back to the deployment-wide LLM_TIMEOUT_MS when no call sets one', async () => {
63
+ const service = fakeService('{"items":[],"resolutions":[]}');
64
+ const extractor = createCorpusExtractor({
65
+ service: service as never,
66
+ env: { LLM_TIMEOUT_MS: '120000' },
67
+ });
68
+
69
+ await extractor.extract({ documents: [] });
70
+
71
+ expect(service.chat.mock.calls[0][2]).toEqual({ timeout: 120_000 });
72
+ });
73
+
74
+ it('sends nothing at all when neither is set, so the provider default stands', async () => {
75
+ const service = fakeService('{"items":[],"resolutions":[]}');
76
+ const extractor = createCorpusExtractor({ service: service as never, env: {} });
77
+
78
+ await extractor.extract({ documents: [] });
79
+
80
+ expect(service.chat.mock.calls[0][2]).toBeUndefined();
81
+ });
82
+
83
+ it('applies to the Ask adapter as well, which shares the seam', async () => {
84
+ const service = fakeService('{"answer":"x","citations":[]}');
85
+ const llm = createCorpusLlm({ service: service as never, timeoutMs: 90_000, env: {} });
86
+
87
+ await llm.answer({ mode: 'ask', question: 'x', documents: [] } as never);
88
+
89
+ expect(service.chat.mock.calls[0][2]).toEqual({ timeout: 90_000 });
90
+ });
91
+ });
@@ -311,6 +311,53 @@ function askApiOver(api: CorpusApi, extra: Partial<CorpusAskApi> = {}): CorpusAs
311
311
  };
312
312
  }
313
313
 
314
+ describe('a withheld field is SHOWN as withheld, not as empty', () => {
315
+ // bd startsim-768w.38.33. The tenant strips a restricted attribute and names
316
+ // it back; if the screen renders the gap silently, the reader concludes the
317
+ // meeting had no transcript — which is a stronger, and wrong, claim than the
318
+ // gate ever made.
319
+
320
+ it('LibraryPage names what was held back on the open record', async () => {
321
+ const withheld: SourceDocument = {
322
+ ...ownDocuments()[0],
323
+ id: 'doc_withheld',
324
+ title: 'Personal check-in',
325
+ withheldFields: ['body'],
326
+ };
327
+ const api = createFixtureCorpusApi({
328
+ scope: SCOPE, documents: [withheld], moneyEvents: [],
329
+ });
330
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId="doc_withheld" />);
331
+
332
+ expect(await screen.findByText(/not shared with you/i)).toBeInTheDocument();
333
+ expect(screen.getByText(/body/)).toBeInTheDocument();
334
+ });
335
+
336
+ it('AskPage says the answer may be incomplete', async () => {
337
+ const user = userEvent.setup();
338
+ const api: CorpusAskApi = {
339
+ async ask() {
340
+ return {
341
+ status: 'answered',
342
+ answer: 'They agreed to revisit the lease in Q3.',
343
+ citations: [],
344
+ considered: [],
345
+ unresolvedCitations: [],
346
+ droppedOutOfScope: 0,
347
+ withheldFields: ['body'],
348
+ provider: { provider: 'fixture', model: 'fixture-echo', live: false },
349
+ };
350
+ },
351
+ };
352
+ render(<AskPage api={api} scope={SCOPE} />);
353
+ await user.type(screen.getByRole('textbox'), 'lease');
354
+ await user.click(screen.getByRole('button', { name: 'Ask' }));
355
+
356
+ const answer = await screen.findByRole('region', { name: 'Answer' });
357
+ expect(within(answer).getByText(/only partly shared with you/i)).toBeInTheDocument();
358
+ });
359
+ });
360
+
314
361
  describe('AskPage — an answer is only an answer when it is traceable', () => {
315
362
  it('renders the answer with a citation per record, clickable through to it', async () => {
316
363
  const user = userEvent.setup();