@teleporthq/teleport-plugin-next-workflows 0.43.58 → 0.43.60
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/__tests__/ai-chat-rag-sql-parameterization.test.ts +28 -8
- package/__tests__/ai-chat-tool-dispatch-segments.test.ts +110 -0
- package/__tests__/ai-custom-prompt-json-mode.test.ts +78 -0
- package/__tests__/ai-select-database-data-guard.test.ts +218 -0
- package/__tests__/ai-select-database-data-handler.test.ts +237 -0
- package/__tests__/cart-get-total-storefront-tax.test.ts +5 -0
- package/__tests__/client-config-redaction.test.ts +34 -2
- package/__tests__/controlled-select-plugin.test.ts +170 -0
- package/__tests__/controlled-select.ts +215 -0
- package/__tests__/parameterized-stock-chain.test.ts +453 -0
- package/__tests__/stock-decrement-audit.test.ts +5 -1
- package/__tests__/streaming-chunk-payload.test.ts +91 -0
- package/__tests__/streaming-on-end-env-filter.test.ts +2 -2
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +2 -3
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/controlled-select.d.ts +23 -0
- package/dist/cjs/controlled-select.d.ts.map +1 -0
- package/dist/cjs/controlled-select.js +169 -0
- package/dist/cjs/controlled-select.js.map +1 -0
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +4 -0
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts +23 -0
- package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
- package/dist/cjs/ecommerce/parameterized-stock-chain.js +397 -0
- package/dist/cjs/ecommerce/parameterized-stock-chain.js.map +1 -0
- package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +3 -0
- package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
- package/dist/cjs/ecommerce-customhandler-rewriter.js +41 -1
- package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
- package/dist/cjs/executor-generator.js +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +19 -5
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.js +4 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils.js +1 -1
- package/dist/cjs/nodes/ai/ai-select-database-data.d.ts +3 -0
- package/dist/cjs/nodes/ai/ai-select-database-data.d.ts.map +1 -0
- package/dist/cjs/nodes/ai/ai-select-database-data.js +141 -0
- package/dist/cjs/nodes/ai/ai-select-database-data.js.map +1 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts +18 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.js +388 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-add-item.js +18 -0
- package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-clear.js +4 -0
- package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.js +36 -0
- package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +2 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +5 -0
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +2 -3
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/controlled-select.d.ts +23 -0
- package/dist/esm/controlled-select.d.ts.map +1 -0
- package/dist/esm/controlled-select.js +140 -0
- package/dist/esm/controlled-select.js.map +1 -0
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +4 -0
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/ecommerce/parameterized-stock-chain.d.ts +23 -0
- package/dist/esm/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
- package/dist/esm/ecommerce/parameterized-stock-chain.js +385 -0
- package/dist/esm/ecommerce/parameterized-stock-chain.js.map +1 -0
- package/dist/esm/ecommerce-customhandler-rewriter.d.ts +3 -0
- package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
- package/dist/esm/ecommerce-customhandler-rewriter.js +41 -1
- package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
- package/dist/esm/executor-generator.js +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +19 -5
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.js +4 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils.js +1 -1
- package/dist/esm/nodes/ai/ai-select-database-data.d.ts +3 -0
- package/dist/esm/nodes/ai/ai-select-database-data.d.ts.map +1 -0
- package/dist/esm/nodes/ai/ai-select-database-data.js +138 -0
- package/dist/esm/nodes/ai/ai-select-database-data.js.map +1 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts +18 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.js +384 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.js.map +1 -0
- package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-add-item.js +18 -0
- package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-clear.js +4 -0
- package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.js +36 -0
- package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +2 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +5 -0
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +2 -3
- package/src/controlled-select.ts +167 -0
- package/src/data-api-route-generator.ts +4 -0
- package/src/ecommerce/parameterized-stock-chain.ts +449 -0
- package/src/ecommerce-customhandler-rewriter.ts +71 -3
- package/src/executor-generator.ts +1 -1
- package/src/invoice/api-routes-code.ts +19 -5
- package/src/nodes/ai/ai-custom-prompt.ts +4 -1
- package/src/nodes/ai/ai-provider-utils.ts +1 -1
- package/src/nodes/ai/ai-select-database-data.ts +162 -0
- package/src/nodes/ai/ai-sql-select-guard.ts +402 -0
- package/src/nodes/cart/cart-add-item.ts +18 -0
- package/src/nodes/cart/cart-clear.ts +4 -0
- package/src/nodes/cart/cart-get-total.ts +35 -0
- package/src/nodes/index.ts +2 -0
- package/src/workflow-component-plugin.ts +9 -0
|
@@ -22,14 +22,34 @@ import { generateSqlValidatorCode } from '../src/sql-validator'
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
const SEMANTIC_QUERY = [
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
25
|
+
'WITH corpus AS (',
|
|
26
|
+
' SELECT count(*) AS total_docs,',
|
|
27
|
+
' COALESCE(SUM(LENGTH(content)), 0) AS total_chars',
|
|
28
|
+
' FROM teleport_ai_chat_documents',
|
|
29
|
+
')',
|
|
30
|
+
'SELECT ranked.id, ranked.content, ranked.search_content, ranked.keywords, ranked.similarity',
|
|
31
|
+
'FROM (',
|
|
32
|
+
' SELECT d.id, d.content, d.search_content, d.keywords,',
|
|
33
|
+
' CASE',
|
|
34
|
+
' WHEN q.query_vector IS NULL OR d.embedding_json IS NULL THEN NULL',
|
|
35
|
+
' ELSE 1 - (d.embedding_json::vector <=> q.query_vector)',
|
|
36
|
+
' END AS similarity,',
|
|
37
|
+
' row_number() OVER (',
|
|
38
|
+
' ORDER BY (q.query_vector IS NULL OR d.embedding_json IS NULL) ASC,',
|
|
39
|
+
' CASE',
|
|
40
|
+
' WHEN q.query_vector IS NULL OR d.embedding_json IS NULL THEN NULL',
|
|
41
|
+
' ELSE d.embedding_json::vector <=> q.query_vector',
|
|
42
|
+
' END ASC,',
|
|
43
|
+
' d.id ASC',
|
|
44
|
+
' ) AS rn',
|
|
45
|
+
' FROM teleport_ai_chat_documents d',
|
|
46
|
+
" CROSS JOIN (SELECT NULLIF({{ queryEmbedding }}::text, '')::vector AS query_vector) q",
|
|
47
|
+
') ranked',
|
|
48
|
+
'CROSS JOIN corpus',
|
|
49
|
+
'WHERE (corpus.total_docs <= 30 AND corpus.total_chars <= 24000)',
|
|
50
|
+
' OR (ranked.similarity IS NOT NULL AND ranked.rn <= 6)',
|
|
51
|
+
'ORDER BY ranked.rn ASC',
|
|
52
|
+
'LIMIT 30',
|
|
33
53
|
].join('\n')
|
|
34
54
|
|
|
35
55
|
const LEXICAL_QUERY = [
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { splitIntoSegments } from '../src/segment-splitter'
|
|
2
|
+
import type { UIDLWorkflow, UIDLWorkflowNode, UIDLWorkflowEdge } from '@teleporthq/teleport-types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The AI Assistant Chat's action layer stands or falls on ONE property of the
|
|
6
|
+
* generated app: the tool must run BEFORE the answer is written, so the reply
|
|
7
|
+
* can report truthfully what happened.
|
|
8
|
+
*
|
|
9
|
+
* The tool runs inside a `general-custom-node`, which the splitter pins to the
|
|
10
|
+
* CLIENT, while both AI calls are SERVER nodes. So the walk has to produce
|
|
11
|
+
* `[server: decide] [client: run] [server: answer]`.
|
|
12
|
+
*
|
|
13
|
+
* The chat used to express this with two plain edges out of a parse node — one
|
|
14
|
+
* into the tool chain, one into the answer — and relied on the answer's PROMPT
|
|
15
|
+
* referencing the tool's summary to force the ordering, because
|
|
16
|
+
* `getTopologicalOrder` turns a `workflowContext` reference into a dependency
|
|
17
|
+
* edge. That worked, but only by accident of the data flow: remove the
|
|
18
|
+
* reference and the answer was written before the tool ran.
|
|
19
|
+
*
|
|
20
|
+
* The dispatch now lives inside its own custom node, so the ordering comes from
|
|
21
|
+
* a real EDGE. This test pins both halves: the three-segment shape, and that it
|
|
22
|
+
* survives even with no prompt reference at all.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const ctx = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
|
|
26
|
+
|
|
27
|
+
const node = (
|
|
28
|
+
id: string,
|
|
29
|
+
type: string,
|
|
30
|
+
stepNumber: number,
|
|
31
|
+
config: Record<string, unknown> = {}
|
|
32
|
+
): UIDLWorkflowNode => ({ id, type, label: id, config, stepNumber } as unknown as UIDLWorkflowNode)
|
|
33
|
+
|
|
34
|
+
const edge = (source: string, target: string, sourceHandle?: string): UIDLWorkflowEdge =>
|
|
35
|
+
({ id: `${source}->${target}`, source, target, sourceHandle } as unknown as UIDLWorkflowEdge)
|
|
36
|
+
|
|
37
|
+
/** The chat's shape from the merge script onward. */
|
|
38
|
+
function buildChatWorkflow(options: { answerReferencesOutcome: boolean }): UIDLWorkflow {
|
|
39
|
+
const nodes: UIDLWorkflowNode[] = [
|
|
40
|
+
node('merge', 'general-custom-js', 1, { context: 'server', code: 'x' }),
|
|
41
|
+
node('decide', 'ai-custom-prompt', 2, {
|
|
42
|
+
jsonMode: true,
|
|
43
|
+
prompt: ['ctx:', ctx('merge', ['merge', 'context'])],
|
|
44
|
+
}),
|
|
45
|
+
node('run', 'general-custom-node', 3, {
|
|
46
|
+
customNodeId: 'custom_run_action',
|
|
47
|
+
parameters: [{ key: 'decisionText', value: ctx('decide', ['decide', 'response']) }],
|
|
48
|
+
}),
|
|
49
|
+
node('answer', 'ai-custom-prompt', 4, {
|
|
50
|
+
streaming: true,
|
|
51
|
+
prompt: [
|
|
52
|
+
'Context:',
|
|
53
|
+
ctx('merge', ['merge', 'context']),
|
|
54
|
+
...(options.answerReferencesOutcome
|
|
55
|
+
? ['\nOutcome: ', ctx('run', ['run', 'outcomeText'])]
|
|
56
|
+
: []),
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const edges: UIDLWorkflowEdge[] = [
|
|
62
|
+
edge('merge', 'decide'),
|
|
63
|
+
edge('decide', 'run'),
|
|
64
|
+
edge('run', 'answer'),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
return { name: 'send-chat-message', nodes, edges, trigger: { type: 'manual' } } as UIDLWorkflow
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const segmentOf = (segments: ReturnType<typeof splitIntoSegments>, nodeId: string): number =>
|
|
71
|
+
segments.findIndex((segment) => segment.nodes.some((entry) => entry.id === nodeId))
|
|
72
|
+
|
|
73
|
+
describe('AI chat tool dispatch — segment ordering', () => {
|
|
74
|
+
const segments = splitIntoSegments(buildChatWorkflow({ answerReferencesOutcome: true }))
|
|
75
|
+
|
|
76
|
+
it('runs the action on the CLIENT, where general-custom-node is dispatched', () => {
|
|
77
|
+
// The server handler for general-custom-node is a stub: placed in a server
|
|
78
|
+
// segment the nested custom node never runs and downstream references
|
|
79
|
+
// resolve to the raw marker object.
|
|
80
|
+
expect(segments[segmentOf(segments, 'run')].env).toBe('client')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps both AI calls on the server', () => {
|
|
84
|
+
expect(segments[segmentOf(segments, 'decide')].env).toBe('server')
|
|
85
|
+
expect(segments[segmentOf(segments, 'answer')].env).toBe('server')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('orders the answer AFTER the action', () => {
|
|
89
|
+
const answerSegment = segmentOf(segments, 'answer')
|
|
90
|
+
expect(answerSegment).toBeGreaterThan(segmentOf(segments, 'decide'))
|
|
91
|
+
expect(answerSegment).toBeGreaterThan(segmentOf(segments, 'run'))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('produces exactly the three-segment shape the design depends on', () => {
|
|
95
|
+
expect(segments.map((segment) => segment.env)).toEqual(['server', 'client', 'server'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('holds the ordering on the EDGE, not on the prompt reference', () => {
|
|
99
|
+
// The regression this replaces: with the old two-edges-out-of-parse shape,
|
|
100
|
+
// dropping the prompt reference let the answer overtake the tool. A real
|
|
101
|
+
// edge cannot be overtaken, so the shape is identical either way.
|
|
102
|
+
const withoutReference = splitIntoSegments(
|
|
103
|
+
buildChatWorkflow({ answerReferencesOutcome: false })
|
|
104
|
+
)
|
|
105
|
+
expect(withoutReference.map((segment) => segment.env)).toEqual(['server', 'client', 'server'])
|
|
106
|
+
expect(segmentOf(withoutReference, 'answer')).toBeGreaterThan(
|
|
107
|
+
segmentOf(withoutReference, 'run')
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { aiCustomPrompt } from '../src/nodes/ai/ai-custom-prompt'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `ai-custom-prompt` used to hard-code `jsonMode: false`, so a node whose only
|
|
5
|
+
* job is to emit a machine-readable decision (the AI Assistant Chat's tool
|
|
6
|
+
* router) had no way to ask the provider for a JSON-only reply. The flag is now
|
|
7
|
+
* forwarded from the node config.
|
|
8
|
+
*
|
|
9
|
+
* The generated provider utils are emitted as
|
|
10
|
+
* `var __ai_callProvider = typeof __ai_callProvider !== 'undefined' ? … : <def>`,
|
|
11
|
+
* so defining the name ahead of the handler source substitutes a spy for the
|
|
12
|
+
* real provider call.
|
|
13
|
+
*/
|
|
14
|
+
type ProviderCall = { jsonMode?: boolean; systemMessage?: string; userMessage?: string }
|
|
15
|
+
type PromptHandler = (
|
|
16
|
+
config: Record<string, unknown>,
|
|
17
|
+
context: Record<string, unknown>,
|
|
18
|
+
streamCallback?: unknown
|
|
19
|
+
) => Promise<Record<string, unknown>>
|
|
20
|
+
|
|
21
|
+
function loadHandlerWithProviderSpy(): { handler: PromptHandler; calls: ProviderCall[] } {
|
|
22
|
+
const calls: ProviderCall[] = []
|
|
23
|
+
const source = aiCustomPrompt.generateHandler()
|
|
24
|
+
// eslint-disable-next-line no-new-func
|
|
25
|
+
const factory = new Function(
|
|
26
|
+
'__calls',
|
|
27
|
+
[
|
|
28
|
+
'var __ai_callProvider = function (params) {',
|
|
29
|
+
' __calls.push(params);',
|
|
30
|
+
' return Promise.resolve({ content: \'{"action":"none"}\', usage: { total_tokens: 3 } });',
|
|
31
|
+
'};',
|
|
32
|
+
source,
|
|
33
|
+
'return ai_custom_prompt;',
|
|
34
|
+
].join('\n')
|
|
35
|
+
)
|
|
36
|
+
return { handler: factory(calls) as PromptHandler, calls }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('ai-custom-prompt jsonMode passthrough', () => {
|
|
40
|
+
it('asks the provider for JSON when the node config sets jsonMode', async () => {
|
|
41
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
42
|
+
|
|
43
|
+
const result = await handler({ prompt: 'Decide.', token: 'sk-test', jsonMode: true }, {})
|
|
44
|
+
|
|
45
|
+
expect(calls).toHaveLength(1)
|
|
46
|
+
expect(calls[0].jsonMode).toBe(true)
|
|
47
|
+
expect(result.response).toBe('{"action":"none"}')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('leaves JSON mode off for an ordinary prompt node', async () => {
|
|
51
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
52
|
+
|
|
53
|
+
await handler({ prompt: 'Write a haiku.', token: 'sk-test' }, {})
|
|
54
|
+
|
|
55
|
+
expect(calls[0].jsonMode).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('treats any non-true value as off, so a stale string config cannot enable it', async () => {
|
|
59
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
60
|
+
|
|
61
|
+
await handler({ prompt: 'Hi.', token: 'sk-test', jsonMode: 'true' }, {})
|
|
62
|
+
|
|
63
|
+
expect(calls[0].jsonMode).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('never sends jsonMode down the streaming path', async () => {
|
|
67
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
68
|
+
const chunks: string[] = []
|
|
69
|
+
|
|
70
|
+
// No stream callback => the streaming branch is skipped; with one, the
|
|
71
|
+
// handler must not reach __ai_callProvider at all.
|
|
72
|
+
await handler({ prompt: 'Hi.', token: 'sk-test', jsonMode: true, streaming: true }, {}, () =>
|
|
73
|
+
chunks.push('x')
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(calls).toHaveLength(0)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { generateAiSqlSelectGuard } from '../src/nodes/ai/ai-sql-select-guard'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The guard is the ONLY thing standing between an AI model's output and the
|
|
5
|
+
* database: the model can be prompt-injected into producing anything, so a
|
|
6
|
+
* miss here is a write/exfiltration primitive, and an over-match breaks the
|
|
7
|
+
* legitimate aggregate queries the node exists for (MIN/MAX/CASE…END).
|
|
8
|
+
* Exercised exactly as the generated app defines it — by evaluating the
|
|
9
|
+
* emitted source.
|
|
10
|
+
*/
|
|
11
|
+
function loadGuard() {
|
|
12
|
+
// eslint-disable-next-line no-new-func
|
|
13
|
+
return new Function(
|
|
14
|
+
`${generateAiSqlSelectGuard()}
|
|
15
|
+
return {
|
|
16
|
+
validate: __aisql_validateSelectQuery,
|
|
17
|
+
enforceLimit: __aisql_enforceLimit,
|
|
18
|
+
buildSystemPrompt: __aisql_buildSystemPrompt,
|
|
19
|
+
};`
|
|
20
|
+
)() as {
|
|
21
|
+
validate: (sql: string, allowedTables: string[]) => { valid: boolean; reason?: string }
|
|
22
|
+
enforceLimit: (sql: string, maxRows: number) => string
|
|
23
|
+
buildSystemPrompt: (tableSchemas: unknown, allowedTables: string[], maxRows: number) => string
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { validate, enforceLimit, buildSystemPrompt } = loadGuard()
|
|
28
|
+
const ALLOWED = ['teleport_products', 'teleport_blog_posts']
|
|
29
|
+
|
|
30
|
+
describe('accepts the queries the node exists for', () => {
|
|
31
|
+
it('plain SELECT over an allowed table', () => {
|
|
32
|
+
expect(
|
|
33
|
+
validate('SELECT "id", "name", "price" FROM "teleport_products" LIMIT 10', ALLOWED)
|
|
34
|
+
).toEqual({
|
|
35
|
+
valid: true,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('aggregates and superlatives (MIN/MAX/ORDER BY … LIMIT 1)', () => {
|
|
40
|
+
expect(
|
|
41
|
+
validate(
|
|
42
|
+
'SELECT "name", "price" FROM "teleport_products" ORDER BY "price" DESC LIMIT 1',
|
|
43
|
+
ALLOWED
|
|
44
|
+
).valid
|
|
45
|
+
).toBe(true)
|
|
46
|
+
expect(validate('SELECT MAX("price") FROM "teleport_products"', ALLOWED).valid).toBe(true)
|
|
47
|
+
expect(validate('SELECT COUNT(*) FROM "teleport_blog_posts";', ALLOWED).valid).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('CASE … END (the forms-worker validator regression)', () => {
|
|
51
|
+
expect(
|
|
52
|
+
validate(
|
|
53
|
+
`SELECT "name", CASE WHEN "price" > 100 THEN 'premium' ELSE 'standard' END AS tier FROM "teleport_products"`,
|
|
54
|
+
ALLOWED
|
|
55
|
+
).valid
|
|
56
|
+
).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('WITH … SELECT over allowed tables, CTE referenced by name', () => {
|
|
60
|
+
expect(
|
|
61
|
+
validate(
|
|
62
|
+
'WITH ranked AS (SELECT "name", "price" FROM "teleport_products") SELECT * FROM ranked ORDER BY "price" ASC LIMIT 1',
|
|
63
|
+
ALLOWED
|
|
64
|
+
).valid
|
|
65
|
+
).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('JOIN with an alias between allowed tables', () => {
|
|
69
|
+
expect(
|
|
70
|
+
validate(
|
|
71
|
+
'SELECT p."name" FROM "teleport_products" p JOIN "teleport_blog_posts" b ON b."id" = p."id"',
|
|
72
|
+
ALLOWED
|
|
73
|
+
).valid
|
|
74
|
+
).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('set-returning functions in FROM are not table refs', () => {
|
|
78
|
+
expect(
|
|
79
|
+
validate(
|
|
80
|
+
`SELECT kv.key FROM "teleport_products" p, jsonb_each_text(p."metadata") kv`,
|
|
81
|
+
ALLOWED
|
|
82
|
+
).valid
|
|
83
|
+
).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keywords inside string literals do not false-positive', () => {
|
|
87
|
+
expect(
|
|
88
|
+
validate(`SELECT "name" FROM "teleport_products" WHERE "status" = 'deleted'`, ALLOWED).valid
|
|
89
|
+
).toBe(true)
|
|
90
|
+
expect(
|
|
91
|
+
validate(
|
|
92
|
+
`SELECT "name" FROM "teleport_products" WHERE "description" = 'DROP by the store'`,
|
|
93
|
+
ALLOWED
|
|
94
|
+
).valid
|
|
95
|
+
).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('rejects everything that is not a single read-only SELECT', () => {
|
|
100
|
+
const rejected = (sql: string, tables: string[] = ALLOWED) =>
|
|
101
|
+
validate(sql, tables).valid === false
|
|
102
|
+
|
|
103
|
+
it('writes and DDL', () => {
|
|
104
|
+
expect(rejected(`INSERT INTO "teleport_products" ("name") VALUES ('x')`)).toBe(true)
|
|
105
|
+
expect(rejected(`UPDATE "teleport_products" SET "price" = 0`)).toBe(true)
|
|
106
|
+
expect(rejected(`DELETE FROM "teleport_products"`)).toBe(true)
|
|
107
|
+
expect(rejected(`DROP TABLE "teleport_products"`)).toBe(true)
|
|
108
|
+
expect(rejected(`SELECT * INTO backup FROM "teleport_products"`)).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('stacked statements and comments', () => {
|
|
112
|
+
expect(rejected(`SELECT 1; DELETE FROM "teleport_products"`)).toBe(true)
|
|
113
|
+
expect(rejected(`SELECT "name" FROM "teleport_products" -- hidden`)).toBe(true)
|
|
114
|
+
expect(rejected(`SELECT /* smuggle */ "name" FROM "teleport_products"`)).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('system catalogs and pg_* objects', () => {
|
|
118
|
+
expect(rejected(`SELECT * FROM information_schema.tables`)).toBe(true)
|
|
119
|
+
expect(rejected(`SELECT * FROM pg_catalog.pg_tables`)).toBe(true)
|
|
120
|
+
expect(rejected(`SELECT * FROM pg_stat_activity`)).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('tables outside the allowlist, in FROM, JOIN, and subqueries', () => {
|
|
124
|
+
expect(rejected(`SELECT * FROM "users"`)).toBe(true)
|
|
125
|
+
expect(rejected(`SELECT * FROM "teleport_products" JOIN "users" u ON u."id" = 1`)).toBe(true)
|
|
126
|
+
expect(
|
|
127
|
+
rejected(`SELECT * FROM "teleport_products" WHERE "id" IN (SELECT "id" FROM "users")`)
|
|
128
|
+
).toBe(true)
|
|
129
|
+
expect(rejected(`SELECT * FROM otherschema."teleport_products"`)).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('a CTE cannot launder a non-allowed table', () => {
|
|
133
|
+
expect(rejected(`WITH x AS (SELECT * FROM "users") SELECT * FROM x`)).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('timing/file functions and locks', () => {
|
|
137
|
+
expect(rejected(`SELECT pg_sleep(10)`)).toBe(true)
|
|
138
|
+
expect(rejected(`SELECT current_setting('server_version')`)).toBe(true)
|
|
139
|
+
expect(rejected(`SELECT "name" FROM "teleport_products" FOR SHARE`)).toBe(true)
|
|
140
|
+
// FOR UPDATE falls to the UPDATE keyword rule
|
|
141
|
+
expect(rejected(`SELECT "name" FROM "teleport_products" FOR UPDATE`)).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('transaction control and session state', () => {
|
|
145
|
+
expect(rejected(`BEGIN`)).toBe(true)
|
|
146
|
+
expect(rejected(`SET search_path TO public`)).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('degenerate inputs', () => {
|
|
150
|
+
expect(rejected('')).toBe(true)
|
|
151
|
+
expect(rejected(' ')).toBe(true)
|
|
152
|
+
expect(rejected('SELECT 1'.padEnd(10001, '1'))).toBe(true)
|
|
153
|
+
expect(validate(null as unknown as string, ALLOWED).valid).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('enforceLimit', () => {
|
|
158
|
+
it('appends a LIMIT when the statement has none', () => {
|
|
159
|
+
expect(enforceLimit('SELECT * FROM "teleport_products"', 100)).toBe(
|
|
160
|
+
'SELECT * FROM "teleport_products" LIMIT 100'
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('clamps an oversized trailing LIMIT and keeps a compliant one', () => {
|
|
165
|
+
expect(enforceLimit('SELECT * FROM "teleport_products" LIMIT 5000', 100)).toBe(
|
|
166
|
+
'SELECT * FROM "teleport_products" LIMIT 100'
|
|
167
|
+
)
|
|
168
|
+
expect(enforceLimit('SELECT * FROM "teleport_products" LIMIT 5', 100)).toBe(
|
|
169
|
+
'SELECT * FROM "teleport_products" LIMIT 5'
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('a subquery LIMIT does not satisfy the outer statement', () => {
|
|
174
|
+
const sql = 'SELECT * FROM (SELECT * FROM "teleport_products" LIMIT 5) t WHERE t."price" > 0'
|
|
175
|
+
expect(enforceLimit(sql, 100)).toBe(sql + ' LIMIT 100')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('strips a trailing semicolon and re-clamps maxRows itself', () => {
|
|
179
|
+
expect(enforceLimit('SELECT 1;', 999999)).toBe('SELECT 1 LIMIT 1000')
|
|
180
|
+
expect(enforceLimit('SELECT 1', 0)).toBe('SELECT 1 LIMIT 100')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('buildSystemPrompt', () => {
|
|
185
|
+
it('renders baked schemas and falls back for undescribed tables', () => {
|
|
186
|
+
const prompt = buildSystemPrompt(
|
|
187
|
+
[
|
|
188
|
+
{
|
|
189
|
+
table: 'teleport_products',
|
|
190
|
+
columns: [
|
|
191
|
+
{ name: 'id', type: 'uuid', nullable: false },
|
|
192
|
+
{ name: 'price', type: 'numeric' },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
ALLOWED,
|
|
197
|
+
50
|
|
198
|
+
)
|
|
199
|
+
expect(prompt).toContain('TABLE "teleport_products" ("id" uuid NOT NULL, "price" numeric)')
|
|
200
|
+
expect(prompt).toContain('TABLE "teleport_blog_posts" (column names unknown')
|
|
201
|
+
expect(prompt).toContain('LIMIT of at most 50')
|
|
202
|
+
expect(prompt).toContain('needsQuery')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('teaches the SQL habits the store chat needed in practice', () => {
|
|
206
|
+
const prompt = buildSystemPrompt([], ALLOWED, 50)
|
|
207
|
+
// "what price does the 054 product have?" — partial name, possibly living
|
|
208
|
+
// in a language-suffixed column.
|
|
209
|
+
expect(prompt).toContain('ILIKE')
|
|
210
|
+
expect(prompt).toContain('language-suffixed variants')
|
|
211
|
+
// "most expensive product" with several products at the same top price —
|
|
212
|
+
// ORDER BY + LIMIT 1 silently returned only one of them.
|
|
213
|
+
expect(prompt).toContain('MAX()/MIN() subquery')
|
|
214
|
+
expect(prompt).toContain('never ORDER BY with LIMIT 1')
|
|
215
|
+
// Rows must arrive identifiable and linkable.
|
|
216
|
+
expect(prompt).toContain('name/title/slug-like columns')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { aiSelectDatabaseData } from '../src/nodes/ai/ai-select-database-data'
|
|
2
|
+
import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The handler's result is serialized straight to the visitor's browser, so the
|
|
6
|
+
* contract under test is as much about what it must NEVER contain (the
|
|
7
|
+
* generated SQL, schema text, database error messages) as about what it
|
|
8
|
+
* returns. The AI call and fetch are mocked by pre-defining the guarded
|
|
9
|
+
* globals — the emitted `var X = typeof X !== 'undefined' ? X : …` pattern
|
|
10
|
+
* keeps whatever is already in scope.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface MockState {
|
|
14
|
+
aiResponses: string[]
|
|
15
|
+
aiCalls: Array<{ systemMessage: string; userMessage: string; jsonMode: boolean }>
|
|
16
|
+
fetchCalls: Array<{ url: string; body: { query: string; params: unknown[] } }>
|
|
17
|
+
fetchResponse: { ok: boolean; json: unknown }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadHandler(state: MockState) {
|
|
21
|
+
const mocks = `
|
|
22
|
+
var __ai_callProvider = async function (params) {
|
|
23
|
+
state.aiCalls.push({ systemMessage: params.systemMessage, userMessage: params.userMessage, jsonMode: params.jsonMode });
|
|
24
|
+
var next = state.aiResponses.shift();
|
|
25
|
+
if (next === '__throw__') { throw new Error('provider exploded'); }
|
|
26
|
+
return { content: next };
|
|
27
|
+
};
|
|
28
|
+
`
|
|
29
|
+
const source = aiSelectDatabaseData.generateServerHandler!()
|
|
30
|
+
const fetchMock = async (url: string, init: { body: string }) => {
|
|
31
|
+
state.fetchCalls.push({ url, body: JSON.parse(init.body) })
|
|
32
|
+
return {
|
|
33
|
+
ok: state.fetchResponse.ok,
|
|
34
|
+
json: async () => state.fetchResponse.json,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// eslint-disable-next-line no-new-func
|
|
38
|
+
return new Function('state', 'fetch', `${mocks}\n${source}\nreturn ai_select_database_data;`)(
|
|
39
|
+
state,
|
|
40
|
+
fetchMock
|
|
41
|
+
) as (config: unknown, context: Record<string, unknown>) => Promise<any>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeState(overrides: Partial<MockState> = {}): MockState {
|
|
45
|
+
return {
|
|
46
|
+
aiResponses: ['{"needsQuery": true, "query": "SELECT \\"id\\" FROM \\"teleport_products\\""}'],
|
|
47
|
+
aiCalls: [],
|
|
48
|
+
fetchCalls: [],
|
|
49
|
+
fetchResponse: { ok: true, json: { rows: [{ id: '1' }, { id: '2' }] } },
|
|
50
|
+
...overrides,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const BASE_CONFIG = {
|
|
55
|
+
dataSourceId: 'ds1',
|
|
56
|
+
allowedTables: ['teleport_products'],
|
|
57
|
+
prompt: 'What is the cheapest product?',
|
|
58
|
+
model: 'gpt-4o',
|
|
59
|
+
token: 'sk-test',
|
|
60
|
+
tableSchemas: [
|
|
61
|
+
{ table: 'teleport_products', columns: [{ name: 'id', type: 'uuid', nullable: false }] },
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadIsFatalNodeResult(): (result: unknown) => boolean {
|
|
66
|
+
const utilsModule = { exports: {} as { isFatalNodeResult: (result: unknown) => boolean } }
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
68
|
+
new Function('module', 'exports', 'require', generateSharedRuntimeUtilsCode())(
|
|
69
|
+
utilsModule,
|
|
70
|
+
utilsModule.exports,
|
|
71
|
+
() => ({})
|
|
72
|
+
)
|
|
73
|
+
return utilsModule.exports.isFatalNodeResult
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('happy path', () => {
|
|
77
|
+
it('generates, validates, executes, and returns only { executed, rowCount, rows, truncated }', async () => {
|
|
78
|
+
const state = makeState()
|
|
79
|
+
const handler = loadHandler(state)
|
|
80
|
+
const result = await handler(BASE_CONFIG, {})
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({
|
|
83
|
+
executed: true,
|
|
84
|
+
rowCount: 2,
|
|
85
|
+
rows: [{ id: '1' }, { id: '2' }],
|
|
86
|
+
// Two rows under a cap of 50: nothing was cut off, so a caller may
|
|
87
|
+
// report this as a complete result.
|
|
88
|
+
truncated: false,
|
|
89
|
+
})
|
|
90
|
+
expect(state.aiCalls).toHaveLength(1)
|
|
91
|
+
expect(state.aiCalls[0].jsonMode).toBe(true)
|
|
92
|
+
expect(state.aiCalls[0].systemMessage).toContain('TABLE "teleport_products"')
|
|
93
|
+
expect(state.fetchCalls).toHaveLength(1)
|
|
94
|
+
expect(state.fetchCalls[0].url).toBe('/api/data/ds1/raw-query')
|
|
95
|
+
// The executed SQL carries the enforced LIMIT
|
|
96
|
+
expect(state.fetchCalls[0].body.query).toBe('SELECT "id" FROM "teleport_products" LIMIT 100')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('respects the AI deciding no query is needed, without touching the database', async () => {
|
|
100
|
+
const state = makeState({ aiResponses: ['{"needsQuery": false, "query": null}'] })
|
|
101
|
+
const handler = loadHandler(state)
|
|
102
|
+
const result = await handler(BASE_CONFIG, {})
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({ executed: false, rows: [], rowCount: 0, truncated: false })
|
|
105
|
+
expect(state.fetchCalls).toHaveLength(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('retries once with feedback when the first SQL is rejected', async () => {
|
|
109
|
+
const state = makeState({
|
|
110
|
+
aiResponses: [
|
|
111
|
+
'{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}',
|
|
112
|
+
'{"needsQuery": true, "query": "SELECT \\"id\\" FROM \\"teleport_products\\" LIMIT 5"}',
|
|
113
|
+
],
|
|
114
|
+
})
|
|
115
|
+
const handler = loadHandler(state)
|
|
116
|
+
const result = await handler(BASE_CONFIG, {})
|
|
117
|
+
|
|
118
|
+
expect(result.executed).toBe(true)
|
|
119
|
+
expect(state.aiCalls).toHaveLength(2)
|
|
120
|
+
expect(state.aiCalls[1].userMessage).toContain('was rejected')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('failure contract — nothing sensitive ever leaves the handler', () => {
|
|
125
|
+
const flatten = (value: unknown): string => JSON.stringify(value)
|
|
126
|
+
|
|
127
|
+
it('rejected SQL on both attempts fails closed with a fixed code', async () => {
|
|
128
|
+
const bad = '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}'
|
|
129
|
+
const state = makeState({ aiResponses: [bad, bad] })
|
|
130
|
+
const handler = loadHandler(state)
|
|
131
|
+
const result = await handler(BASE_CONFIG, {})
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual({
|
|
134
|
+
error: true,
|
|
135
|
+
message: 'AI database query failed (sql_rejected)',
|
|
136
|
+
code: 'sql_rejected',
|
|
137
|
+
})
|
|
138
|
+
expect(flatten(result)).not.toContain('DELETE')
|
|
139
|
+
expect(flatten(result)).not.toContain('teleport_products')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('never forwards the data route error body (pg errors quote the SQL)', async () => {
|
|
143
|
+
const state = makeState({
|
|
144
|
+
fetchResponse: {
|
|
145
|
+
ok: false,
|
|
146
|
+
json: { error: 'syntax error at or near "SELECT \\"id\\" FROM teleport_products"' },
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
const handler = loadHandler(state)
|
|
150
|
+
const result = await handler(BASE_CONFIG, {})
|
|
151
|
+
|
|
152
|
+
expect(result.code).toBe('query_failed')
|
|
153
|
+
expect(flatten(result)).not.toContain('syntax error')
|
|
154
|
+
expect(flatten(result)).not.toContain('SELECT')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('optional: true converts every failure into skipped data', async () => {
|
|
158
|
+
const state = makeState({ aiResponses: ['__throw__'] })
|
|
159
|
+
const handler = loadHandler(state)
|
|
160
|
+
const result = await handler({ ...BASE_CONFIG, optional: true }, {})
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
executed: false,
|
|
164
|
+
rows: [],
|
|
165
|
+
rowCount: 0,
|
|
166
|
+
truncated: false,
|
|
167
|
+
skipped: true,
|
|
168
|
+
skipReason: 'provider_error',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('missing configuration fails before any AI call', async () => {
|
|
173
|
+
const state = makeState()
|
|
174
|
+
const handler = loadHandler(state)
|
|
175
|
+
const result = await handler({ ...BASE_CONFIG, allowedTables: [] }, {})
|
|
176
|
+
|
|
177
|
+
expect(result.code).toBe('missing_configuration')
|
|
178
|
+
expect(state.aiCalls).toHaveLength(0)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('the executor treats default failures as fatal and optional ones as data', async () => {
|
|
182
|
+
const isFatalNodeResult = loadIsFatalNodeResult()
|
|
183
|
+
const bad = '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}'
|
|
184
|
+
|
|
185
|
+
const fatalState = makeState({ aiResponses: [bad, bad] })
|
|
186
|
+
const fatal = await loadHandler(fatalState)(BASE_CONFIG, {})
|
|
187
|
+
const optionalState = makeState({ aiResponses: [bad, bad] })
|
|
188
|
+
const optional = await loadHandler(optionalState)({ ...BASE_CONFIG, optional: true }, {})
|
|
189
|
+
|
|
190
|
+
expect(isFatalNodeResult(fatal)).toBe(true)
|
|
191
|
+
expect(isFatalNodeResult(optional)).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('registration', () => {
|
|
196
|
+
it('is a server node so its config (schemas, allowlist) is redacted from client bundles', () => {
|
|
197
|
+
expect(aiSelectDatabaseData.executionEnv).toBe('server')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('telling a capped result apart from a complete one', () => {
|
|
202
|
+
it('flags a full page of rows as truncated', async () => {
|
|
203
|
+
// The statement is capped, so a result that fills the cap is one page of a
|
|
204
|
+
// larger answer. Reporting its length as a total is what made the chat say
|
|
205
|
+
// "there are 50 products" for a catalogue of 220.
|
|
206
|
+
const rows = Array.from({ length: 50 }, (_, index) => ({ id: String(index) }))
|
|
207
|
+
const state = makeState({ fetchResponse: { ok: true, json: { rows } } })
|
|
208
|
+
const handler = loadHandler(state)
|
|
209
|
+
|
|
210
|
+
const result = (await handler({ ...BASE_CONFIG, maxRows: 50 }, {})) as {
|
|
211
|
+
truncated: boolean
|
|
212
|
+
rowCount: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
expect(result.truncated).toBe(true)
|
|
216
|
+
expect(result.rowCount).toBe(50)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not flag an aggregate answer as truncated', async () => {
|
|
220
|
+
const state = makeState({
|
|
221
|
+
aiResponses: [
|
|
222
|
+
'{"needsQuery": true, "query": "SELECT COUNT(*) AS \\"total_count\\" FROM \\"teleport_products\\""}',
|
|
223
|
+
],
|
|
224
|
+
fetchResponse: { ok: true, json: { rows: [{ total_count: 223 }] } },
|
|
225
|
+
})
|
|
226
|
+
const handler = loadHandler(state)
|
|
227
|
+
|
|
228
|
+
const result = (await handler({ ...BASE_CONFIG, maxRows: 50 }, {})) as {
|
|
229
|
+
truncated: boolean
|
|
230
|
+
rows: unknown[]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One row carrying the real total — the only shape that can state one.
|
|
234
|
+
expect(result.truncated).toBe(false)
|
|
235
|
+
expect(result.rows).toEqual([{ total_count: 223 }])
|
|
236
|
+
})
|
|
237
|
+
})
|