streamtrust 0.1.1 → 0.3.0

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.
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "streamtrust-vercel-ai-reference-app",
3
+ "private": true,
4
+ "description": "Minimal Next.js + AI SDK chat used as the StreamTrust vercel-ai preset's application under test",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "scripts": {
10
+ "build": "next build",
11
+ "start": "next start"
12
+ },
13
+ "dependencies": {
14
+ "@ai-sdk/openai-compatible": "3.0.68",
15
+ "@ai-sdk/react": "4.0.141",
16
+ "ai": "7.0.138",
17
+ "next": "16.4.3",
18
+ "react": "19.3.0",
19
+ "react-dom": "19.3.0"
20
+ }
21
+ }
@@ -0,0 +1,27 @@
1
+ import { assertIsolatedCheckout } from './config.mjs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const root = resolve(
7
+ process.env.STREAMTRUST_VERCEL_AI_ROOT ||
8
+ fileURLToPath(new URL('./reference-app', import.meta.url)),
9
+ );
10
+ if (Number(process.versions.node.split('.')[0]) < 22)
11
+ throw new Error('The vercel-ai preset requires Node.js 22+ (AI SDK 7)');
12
+ if (!existsSync(resolve(root, 'package.json')))
13
+ throw new Error(`No package.json in ${root}`);
14
+ assertIsolatedCheckout(root);
15
+ const run = (cmd, args) =>
16
+ execFileSync(cmd, args, {
17
+ cwd: root,
18
+ stdio: 'inherit',
19
+ env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
20
+ });
21
+ // The bundled shrinkwrap pins the fixture; external checkouts retain their own lockfile.
22
+ run('npm', [
23
+ existsSync(resolve(root, 'npm-shrinkwrap.json')) || existsSync(resolve(root, 'package-lock.json')) ? 'ci' : 'install',
24
+ '--no-audit',
25
+ '--no-fund',
26
+ ]);
27
+ run('npm', ['run', 'build']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamtrust",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Adapter-driven CI verification for streaming application lifecycle guarantees",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,13 +14,28 @@
14
14
  ".": {
15
15
  "types": "./src/index.d.ts",
16
16
  "import": "./src/index.js"
17
+ },
18
+ "./checks": {
19
+ "types": "./src/checks.d.ts",
20
+ "import": "./src/checks.js"
17
21
  }
18
22
  },
19
23
  "types": "src/index.d.ts",
20
24
  "files": [
21
25
  "bin/",
22
26
  "src/",
23
- "examples/",
27
+ "examples/http.adapter.mjs",
28
+ "examples/demo-app.mjs",
29
+ "examples/librechat/*.mjs",
30
+ "examples/librechat/README.md",
31
+ "examples/vercel-ai/*.mjs",
32
+ "examples/vercel-ai/README.md",
33
+ "examples/vercel-ai/reference-app/package.json",
34
+ "examples/vercel-ai/reference-app/npm-shrinkwrap.json",
35
+ "examples/vercel-ai/reference-app/next.config.mjs",
36
+ "examples/vercel-ai/reference-app/lib/*.js",
37
+ "examples/vercel-ai/reference-app/app/**/*.js",
38
+ "examples/vercel-ai/reference-app/app/**/*.jsx",
24
39
  "README.md",
25
40
  "LICENSE"
26
41
  ],
@@ -0,0 +1,36 @@
1
+ import type { ExternalObservation } from './index.js';
2
+ export type LifecycleScenario =
3
+ | 'persistence'
4
+ | 'cancellation'
5
+ | 'regeneration'
6
+ | 'error-retry';
7
+ export const LIFECYCLE_SCENARIOS: readonly LifecycleScenario[];
8
+ export interface LifecycleObservation {
9
+ scenario: LifecycleScenario;
10
+ expected: string;
11
+ original: string;
12
+ previousPrefix: string;
13
+ beforeReload: string;
14
+ reloaded: string;
15
+ messages: Array<{
16
+ id: string;
17
+ parentId: string;
18
+ text: string;
19
+ assistant: boolean;
20
+ unfinished: boolean;
21
+ error: boolean;
22
+ }>;
23
+ callsBeforeReload: number;
24
+ callsAfterReload: number;
25
+ cancellation?: {
26
+ acknowledged: boolean;
27
+ providerClosed: boolean;
28
+ providerFinished: boolean;
29
+ lateCompletionRejected: boolean;
30
+ };
31
+ failure?: { saved: boolean; visibleAfterReload: boolean };
32
+ }
33
+ /** Evaluate in-memory observations; returns only fixed check names and allowlisted numeric evidence. */
34
+ export function evaluateLifecycle(
35
+ observation: LifecycleObservation,
36
+ ): ExternalObservation;
package/src/checks.js ADDED
@@ -0,0 +1,105 @@
1
+ // Raw observations remain in memory. Returned evidence is deliberately allowlisted.
2
+ export const LIFECYCLE_SCENARIOS = Object.freeze([
3
+ 'persistence',
4
+ 'cancellation',
5
+ 'regeneration',
6
+ 'error-retry',
7
+ ]);
8
+ export function evaluateLifecycle(o) {
9
+ const text = (v) => typeof v === 'string' && v.length > 0;
10
+ const bool = (v) => typeof v === 'boolean';
11
+ if (
12
+ !o ||
13
+ !LIFECYCLE_SCENARIOS.includes(o.scenario) ||
14
+ !text(o.expected) ||
15
+ !text(o.original) ||
16
+ !text(o.previousPrefix) ||
17
+ typeof o.beforeReload !== 'string' ||
18
+ typeof o.reloaded !== 'string' ||
19
+ !Array.isArray(o.messages) ||
20
+ o.messages.some(
21
+ (m) =>
22
+ !m ||
23
+ !text(m.id) ||
24
+ typeof m.parentId !== 'string' ||
25
+ typeof m.text !== 'string' ||
26
+ !bool(m.assistant) ||
27
+ !bool(m.unfinished) ||
28
+ !bool(m.error),
29
+ ) ||
30
+ !Number.isSafeInteger(o.callsBeforeReload) ||
31
+ o.callsBeforeReload < 1 ||
32
+ !Number.isSafeInteger(o.callsAfterReload) ||
33
+ o.callsAfterReload < 0
34
+ )
35
+ throw new Error('Invalid lifecycle observation');
36
+ const checks = [];
37
+ const check = (name, passed) => checks.push({ name, passed });
38
+ const matching = o.messages.filter(
39
+ (m) => m.assistant && m.text.trim() === o.expected.trim(),
40
+ );
41
+ check('saved.answer-present', matching.length > 0);
42
+ check('saved.single-matching-answer', matching.length === 1);
43
+ check('ui.answer-visible', o.beforeReload.includes(o.expected));
44
+ check(
45
+ 'saved.lifecycle-state',
46
+ matching.length === 1 &&
47
+ (o.scenario === 'cancellation'
48
+ ? matching[0].unfinished === true
49
+ : matching[0].unfinished === false && !matching[0].error),
50
+ );
51
+ check('reload.answer-preserved', o.reloaded.includes(o.expected));
52
+ check('reload.no-new-generation', o.callsAfterReload === o.callsBeforeReload);
53
+ if (o.scenario === 'cancellation') {
54
+ const c = o.cancellation;
55
+ if (
56
+ !c ||
57
+ [
58
+ 'acknowledged',
59
+ 'providerClosed',
60
+ 'providerFinished',
61
+ 'lateCompletionRejected',
62
+ ].some((k) => !bool(c[k]))
63
+ )
64
+ throw new Error('Invalid cancellation observation');
65
+ check('cancel.server-acknowledged', c.acknowledged);
66
+ check('cancel.provider-aborted', c.providerClosed && !c.providerFinished);
67
+ check('cancel.late-completion-rejected', c.lateCompletionRejected);
68
+ }
69
+ if (o.scenario === 'regeneration') {
70
+ const original = o.messages.filter(
71
+ (m) => m.assistant && m.text === o.original,
72
+ );
73
+ check('retry.original-branch-preserved', original.length === 1);
74
+ check(
75
+ 'retry.distinct-sibling',
76
+ original.length === 1 &&
77
+ matching.length === 1 &&
78
+ original[0].id !== matching[0].id &&
79
+ original[0].parentId === matching[0].parentId,
80
+ );
81
+ }
82
+ if (o.scenario === 'error-retry') {
83
+ const f = o.failure;
84
+ if (!f || !bool(f.saved) || !bool(f.visibleAfterReload))
85
+ throw new Error('Invalid failure observation');
86
+ check('error.saved-before-retry', f.saved);
87
+ check('error.visible-after-reload', f.visibleAfterReload);
88
+ }
89
+ if (['regeneration', 'error-retry'].includes(o.scenario))
90
+ check(
91
+ 'retry.previous-answer-not-active',
92
+ !o.reloaded.includes(o.previousPrefix),
93
+ );
94
+ return {
95
+ checks,
96
+ evidence: {
97
+ policy: 'summary-only-v1',
98
+ checkVersion: 1,
99
+ savedMessageCount: o.messages.length,
100
+ matchingAnswerCount: matching.length,
101
+ providerCallsBeforeReload: o.callsBeforeReload,
102
+ providerCallsAfterReload: o.callsAfterReload,
103
+ },
104
+ };
105
+ }
package/src/cli.d.ts CHANGED
@@ -1 +1,3 @@
1
1
  export function main(args: string[]): Promise<number>;
2
+ /** Preset names accepted by `streamtrust init --preset`; `http` is the default. */
3
+ export function presets(): string[];
package/src/cli.js CHANGED
@@ -1,11 +1,29 @@
1
1
  import { fork } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { resolve } from 'node:path';
3
+ import { resolve, join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { mkdir, copyFile, open, unlink, readFile } from 'node:fs/promises';
6
6
  import { constants } from 'node:fs';
7
7
  import { VERSION, failureReport } from './index.js';
8
- const HELP = `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory]\n streamtrust verify --adapter ./streamtrust/adapter.mjs [options]\n\nOptions:\n --output <directory> Reports (default: reports/streamtrust)\n --repetitions <1-1000> Repetitions per scenario (default: 3)\n --timeout-ms <integer> Total run deadline including setup/cleanup (default: 300000)\n --help Show help\n --version Show version\n\nExit codes: 0 all pass; 1 failed/unsupported checks; 2 setup, timeout or harness error.\nAdapters execute trusted local code. JavaScript ESM (.mjs) works without dependencies.\n`;
8
+ const EXAMPLES = fileURLToPath(new URL('../examples/', import.meta.url));
9
+ // Explicit source inventory: never copy local builds, dependencies or configuration.
10
+ const PRESETS = {
11
+ http: [['http.adapter.mjs', 'adapter.mjs'], ['demo-app.mjs', 'demo-app.mjs']],
12
+ librechat: ['adapter.mjs', 'provider.mjs', 'setup.mjs', 'README.md'].map(
13
+ (name) => [`librechat/${name}`, name]),
14
+ 'vercel-ai': [
15
+ 'adapter.mjs', 'provider.mjs', 'setup.mjs', 'config.mjs', 'README.md',
16
+ 'reference-app/package.json', 'reference-app/npm-shrinkwrap.json',
17
+ 'reference-app/next.config.mjs', 'reference-app/lib/store.js',
18
+ 'reference-app/app/layout.jsx', 'reference-app/app/page.jsx',
19
+ 'reference-app/app/api/chat/route.js', 'reference-app/app/chat/[id]/chat.jsx',
20
+ 'reference-app/app/chat/[id]/page.jsx',
21
+ ].map((name) => [`vercel-ai/${name}`, name]),
22
+ };
23
+ export function presets() { return Object.keys(PRESETS); }
24
+ function presetFiles(preset) { return PRESETS[preset]; }
25
+ const HELP = () =>
26
+ `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory] [--preset <name>]\n streamtrust verify --adapter ./streamtrust/adapter.mjs [options]\n\nOptions:\n --output <directory> Reports (default: reports/streamtrust)\n --repetitions <1-1000> Repetitions per scenario (default: 3)\n --timeout-ms <integer> Total run deadline including setup/cleanup (default: 300000)\n --help Show help\n --version Show version\n\nPresets: ${presets().join(', ')} (default: http)\n\nExit codes: 0 all pass; 1 failed/unsupported checks; 2 setup, timeout or harness error.\nAdapters execute trusted local code. JavaScript ESM (.mjs) works without dependencies.\n`;
9
27
  export async function main(args) {
10
28
  let outputDir;
11
29
  try {
@@ -14,24 +32,33 @@ export async function main(args) {
14
32
  return 0;
15
33
  }
16
34
  if (!args.length || args.includes('--help')) {
17
- console.log(HELP);
35
+ console.log(HELP());
18
36
  return 0;
19
37
  }
20
38
  if (args[0] === 'init') {
21
- if (args.length > 2 || args[1]?.startsWith('-'))
22
- throw new Error('Usage: streamtrust init [directory]');
23
- const dir = resolve(args[1] ?? 'streamtrust');
39
+ const initArgs = args.slice(1);
40
+ const presetIndex = initArgs.indexOf('--preset');
41
+ let preset = 'http';
42
+ if (presetIndex !== -1) {
43
+ preset = initArgs[presetIndex + 1];
44
+ initArgs.splice(presetIndex, 2);
45
+ if (!presets().includes(preset))
46
+ throw new Error(
47
+ `Unknown init preset; available: ${presets().join(', ')}`,
48
+ );
49
+ }
50
+ if (initArgs.length > 1 || initArgs[0]?.startsWith('-'))
51
+ throw new Error('Invalid init arguments');
52
+ const dir = resolve(initArgs[0] ?? 'streamtrust');
24
53
  await mkdir(dir, { recursive: true });
25
54
  // Exclusive copies preserve existing adapters. No generated config overrides user files.
26
55
  const created = [];
27
56
  try {
28
- for (const [source, target] of [
29
- ['http.adapter.mjs', 'adapter.mjs'],
30
- ['demo-app.mjs', 'demo-app.mjs'],
31
- ]) {
57
+ for (const [source, target] of presetFiles(preset)) {
32
58
  const destination = resolve(dir, target);
59
+ await mkdir(dirname(destination), { recursive: true });
33
60
  await copyFile(
34
- new URL(`../examples/${source}`, import.meta.url),
61
+ join(EXAMPLES, source),
35
62
  destination,
36
63
  constants.COPYFILE_EXCL,
37
64
  );
package/src/index.js CHANGED
@@ -63,13 +63,15 @@ export async function writeReports(outputDir, report) {
63
63
  const rows = report.results;
64
64
  const screenshots = new Set();
65
65
  await Promise.all(
66
- rows.map(async (row) => {
67
- const path = `${row.scenario}-${row.repetition}.png`;
68
- try {
69
- await access(resolve(outputDir, path));
70
- screenshots.add(path);
71
- } catch {}
72
- }),
66
+ (report.metadata?.evidencePolicy === 'summary-only-v1' ? [] : rows).map(
67
+ async (row) => {
68
+ const path = `${row.scenario}-${row.repetition}.png`;
69
+ try {
70
+ await access(resolve(outputDir, path));
71
+ screenshots.add(path);
72
+ } catch {}
73
+ },
74
+ ),
73
75
  );
74
76
  const suiteCases = report.suiteErrors
75
77
  .map(