@rsconcept/rstool 1.0.0 → 1.0.2

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.
Files changed (43) hide show
  1. package/README.md +22 -4
  2. package/dist/{agent-workflow-D-PSIb-m.d.ts → agent-workflow-Gk0Vfnv1.d.ts} +3 -9
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.js +1 -2
  5. package/dist/models/agent-workflow.d.ts +2 -2
  6. package/dist/models/index.d.ts +3 -3
  7. package/dist/models/index.js +1 -1
  8. package/dist/models/rstool-agent.d.ts +1 -1
  9. package/dist/models/rstool-agent.js +1 -1
  10. package/dist/models/tool-contract.d.ts +1 -1
  11. package/dist/{rstool-agent-kijHA9ML.js → models-9G6ur_pg.js} +242 -34
  12. package/dist/models-9G6ur_pg.js.map +1 -0
  13. package/dist/{rstool-agent-_8bplZnb.d.ts → rstool-agent-D2cQze_b.d.ts} +3 -3
  14. package/dist/session/session-store.js +1 -1
  15. package/dist/{tool-contract-5_Q44DGE.d.ts → tool-contract-0uRGhEfW.d.ts} +2 -2
  16. package/dist/wrapper/stdio-wrapper.js +16 -14
  17. package/dist/wrapper/stdio-wrapper.js.map +1 -1
  18. package/docs/DIAGNOSTICS.md +23 -14
  19. package/examples/build-chocolate-nim-rsform.ts +150 -209
  20. package/examples/kinship/build-rsmodel.ts +5 -3
  21. package/examples/sample/build-rsform.ts +7 -9
  22. package/examples/sample/rsform-session.json +31 -31
  23. package/package.json +6 -4
  24. package/src/mappers/portal-adapter.ts +14 -8
  25. package/src/models/agent-workflow.ts +1 -13
  26. package/src/models/import-detect.test.ts +66 -0
  27. package/src/models/import-detect.ts +6 -3
  28. package/src/models/portal-json.test.ts +38 -0
  29. package/src/models/portal-json.ts +11 -2
  30. package/src/models/rstool-agent.test.ts +402 -3
  31. package/src/models/rstool-agent.ts +61 -17
  32. package/src/session/batch-apply.test.ts +132 -8
  33. package/src/session/batch-apply.ts +38 -0
  34. package/src/session/persistence.test.ts +66 -0
  35. package/src/session/persistence.ts +14 -1
  36. package/src/session/session-store.ts +10 -3
  37. package/src/wrapper/client.test.ts +58 -0
  38. package/src/wrapper/stdio-handler.test.ts +101 -0
  39. package/src/wrapper/stdio-handler.ts +195 -0
  40. package/src/wrapper/stdio-wrapper.ts +2 -195
  41. package/dist/rstool-agent-kijHA9ML.js.map +0 -1
  42. package/dist/session-store-C3jyOSqI.js +0 -142
  43. package/dist/session-store-C3jyOSqI.js.map +0 -1
@@ -6,7 +6,7 @@ import {
6
6
  } from '../mappers/portal-adapter';
7
7
  import { ModelAdapter } from '../mappers/model-adapter';
8
8
  import { SchemaAdapter } from '../mappers/schema-adapter';
9
- import { orderDrafts } from '../session/batch-apply';
9
+ import { orderDrafts, reorderSessionItemsByDrafts } from '../session/batch-apply';
10
10
  import { SessionStore } from '../session/session-store';
11
11
  import {
12
12
  type AgentConstituentaPatch,
@@ -82,6 +82,10 @@ function inferCstType(alias: string): CstType {
82
82
  return CstType.FUNCTION;
83
83
  case 'P':
84
84
  return CstType.PREDICATE;
85
+ case 'N':
86
+ return CstType.NOMINAL;
87
+ case 'T':
88
+ return CstType.STATEMENT;
85
89
  default:
86
90
  throw new Error(`Cannot infer cstType from alias "${alias}"; pass cstType explicitly`);
87
91
  }
@@ -141,7 +145,9 @@ export class RSToolAgent implements RSToolAgentContract {
141
145
 
142
146
  /** @inheritdoc */
143
147
  public applySchemaPatch(input: ApplySchemaPatchInput, sessionId?: string): ApplySchemaPatchResult {
144
- const session = sessionId ? this.setCurrentSession(sessionId) : this.ensureSession(input.initial);
148
+ const session = sessionId
149
+ ? { sessionId: this.resolveSessionId(sessionId), contractVersion: this.contractVersion }
150
+ : this.ensureSession(input.initial);
145
151
  const drafts = this.resolveAgentPatches(session.sessionId, input.items);
146
152
  const result = this.applyConstituents(
147
153
  {
@@ -241,23 +247,30 @@ export class RSToolAgent implements RSToolAgentContract {
241
247
  /** @inheritdoc */
242
248
  public async setModelValues(input: SetModelValuesInput, sessionId?: string): Promise<SessionModelState> {
243
249
  const id = this.resolveSessionId(sessionId);
244
- let state = this.sessions.get(id).state;
250
+ const snapshot = this.sessions.snapshot(id);
245
251
 
246
- if (input.clear?.length) {
247
- const model = await this.evaluation.clearConstituentaValues(state, input.clear);
248
- state = { ...state, model };
249
- this.sessions.replaceState(id, state);
250
- }
252
+ try {
253
+ let state = this.sessions.get(id).state;
251
254
 
252
- if (input.set?.length) {
253
- state = this.sessions.get(id).state;
254
- const model = await this.evaluation.setConstituentaValues(state, { items: input.set });
255
- state = { ...state, model };
256
- this.sessions.replaceState(id, state);
257
- return model;
258
- }
255
+ if (input.clear?.length) {
256
+ const model = await this.evaluation.clearConstituentaValues(state, input.clear);
257
+ state = { ...state, model };
258
+ this.sessions.replaceState(id, state);
259
+ }
259
260
 
260
- return structuredClone(this.sessions.get(id).state.model);
261
+ if (input.set?.length) {
262
+ state = this.sessions.get(id).state;
263
+ const model = await this.evaluation.setConstituentaValues(state, { items: input.set });
264
+ state = { ...state, model };
265
+ this.sessions.replaceState(id, state);
266
+ return model;
267
+ }
268
+
269
+ return structuredClone(this.sessions.get(id).state.model);
270
+ } catch (error) {
271
+ this.sessions.restore(id, snapshot);
272
+ throw error;
273
+ }
261
274
  }
262
275
 
263
276
  /** @inheritdoc */
@@ -303,6 +316,7 @@ export class RSToolAgent implements RSToolAgentContract {
303
316
  const id = this.resolveSessionId(sessionId);
304
317
  const mode = input.mode ?? 'atomic';
305
318
  const ordered = orderDrafts(this.sessions.get(id).state.items, input.drafts);
319
+ const preBatchItemIds = new Set(this.sessions.get(id).state.items.map(item => item.id));
306
320
  const snapshot = this.sessions.snapshot(id);
307
321
  const applied: ConstituentaState[] = [];
308
322
  const failed: ApplyConstituentsResult['failed'] = [];
@@ -325,6 +339,10 @@ export class RSToolAgent implements RSToolAgentContract {
325
339
  }
326
340
  }
327
341
 
342
+ const envelope = this.sessions.get(id);
343
+ reorderSessionItemsByDrafts(envelope.state.items, input.drafts, preBatchItemIds);
344
+ this.sessions.replaceState(id, envelope.state);
345
+
328
346
  return {
329
347
  success: failed.length === 0,
330
348
  applied,
@@ -424,11 +442,37 @@ export class RSToolAgent implements RSToolAgentContract {
424
442
  private resolveAgentPatches(sessionId: string, patches: AgentConstituentaPatch[]): ConstituentaDraft[] {
425
443
  const items = this.sessions.get(sessionId).state.items;
426
444
  const existingByAlias = new Map(items.map(item => [item.alias, item]));
445
+ const usedIds = new Set(items.map(item => item.id));
427
446
  let nextId = items.reduce((max, item) => Math.max(max, item.id), 0) + 1;
428
447
 
448
+ const reserveId = (id: number): void => {
449
+ usedIds.add(id);
450
+ if (id >= nextId) {
451
+ nextId = id + 1;
452
+ }
453
+ };
454
+
455
+ const allocateId = (): number => {
456
+ while (usedIds.has(nextId)) {
457
+ nextId += 1;
458
+ }
459
+ const id = nextId;
460
+ nextId += 1;
461
+ usedIds.add(id);
462
+ return id;
463
+ };
464
+
429
465
  return patches.map(patch => {
430
466
  const existing = existingByAlias.get(patch.alias);
431
- const id = patch.id ?? existing?.id ?? nextId++;
467
+ let id: number;
468
+ if (patch.id !== undefined) {
469
+ id = patch.id;
470
+ reserveId(id);
471
+ } else if (existing !== undefined) {
472
+ id = existing.id;
473
+ } else {
474
+ id = allocateId();
475
+ }
432
476
  const draft = {
433
477
  id,
434
478
  alias: patch.alias,
@@ -1,28 +1,152 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import { CstType } from '../models';
4
- import { orderDrafts } from './batch-apply';
4
+ import { orderDrafts, reorderSessionItemsByDrafts } from './batch-apply';
5
+
6
+ const baseItem = {
7
+ id: 1,
8
+ alias: 'X1',
9
+ cstType: CstType.BASE,
10
+ definitionFormal: '',
11
+ term: '',
12
+ definitionText: '',
13
+ convention: '',
14
+ analysis: { success: true, type: null, valueClass: 'value' as const, diagnostics: [] }
15
+ };
5
16
 
6
17
  describe('orderDrafts', () => {
7
18
  it('orders dependents after suppliers', () => {
8
19
  const ordered = orderDrafts(
20
+ [baseItem],
9
21
  [
22
+ { id: 3, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
23
+ { id: 2, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' }
24
+ ]
25
+ );
26
+ expect(ordered.map(draft => draft.alias)).toEqual(['S1', 'D1']);
27
+ });
28
+
29
+ it('orders multi-hop chains X1 → S1 → D1', () => {
30
+ const ordered = orderDrafts(
31
+ [baseItem],
32
+ [
33
+ { id: 4, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
34
+ { id: 3, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' },
35
+ { id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' }
36
+ ]
37
+ );
38
+ expect(ordered.map(draft => draft.alias)).toEqual(['C1', 'S1', 'D1']);
39
+ });
40
+
41
+ it('orders draft that only references existing session items', () => {
42
+ const ordered = orderDrafts(
43
+ [
44
+ baseItem,
10
45
  {
11
- id: 1,
12
- alias: 'X1',
13
- cstType: CstType.BASE,
14
- definitionFormal: '',
46
+ id: 2,
47
+ alias: 'S1',
48
+ cstType: CstType.STRUCTURED,
49
+ definitionFormal: 'ℬ(X1×X1)',
15
50
  term: '',
16
51
  definitionText: '',
17
52
  convention: '',
18
53
  analysis: { success: true, type: null, valueClass: 'value', diagnostics: [] }
19
54
  }
20
55
  ],
56
+ [{ id: 3, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' }]
57
+ );
58
+ expect(ordered.map(draft => draft.alias)).toEqual(['D1']);
59
+ });
60
+
61
+ it('appends cyclic drafts after topological ids', () => {
62
+ const ordered = orderDrafts(
63
+ [],
21
64
  [
22
- { id: 3, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
23
- { id: 2, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' }
65
+ { id: 1, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'D2' },
66
+ { id: 2, alias: 'D2', cstType: CstType.TERM, definitionFormal: 'D1' }
24
67
  ]
25
68
  );
26
- expect(ordered.map(draft => draft.alias)).toEqual(['S1', 'D1']);
69
+ expect(ordered.map(draft => draft.alias).sort()).toEqual(['D1', 'D2']);
70
+ expect(ordered).toHaveLength(2);
71
+ });
72
+
73
+ it('returns all independent drafts', () => {
74
+ const ordered = orderDrafts(
75
+ [],
76
+ [
77
+ { id: 1, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
78
+ { id: 2, alias: 'C2', cstType: CstType.CONSTANT, definitionFormal: '' }
79
+ ]
80
+ );
81
+ expect(ordered.map(draft => draft.alias).sort()).toEqual(['C1', 'C2']);
82
+ expect(ordered).toHaveLength(2);
83
+ });
84
+ });
85
+
86
+ describe('reorderSessionItemsByDrafts', () => {
87
+ const mk = (id: number, alias: string) => ({
88
+ id,
89
+ alias,
90
+ cstType: CstType.BASE,
91
+ definitionFormal: '',
92
+ term: '',
93
+ definitionText: '',
94
+ convention: '',
95
+ analysis: { success: true, type: null, valueClass: 'value' as const, diagnostics: [] }
96
+ });
97
+
98
+ it('restores full-batch declaration order', () => {
99
+ const items = [mk(4, 'X1'), mk(3, 'C1'), mk(2, 'S1'), mk(1, 'D1')];
100
+ const preBatchItemIds = new Set(items.map(item => item.id));
101
+ reorderSessionItemsByDrafts(
102
+ items,
103
+ [
104
+ { id: 1, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
105
+ { id: 2, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' },
106
+ { id: 3, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
107
+ { id: 4, alias: 'X1', cstType: CstType.BASE, definitionFormal: '' }
108
+ ],
109
+ preBatchItemIds
110
+ );
111
+ expect(items.map(item => item.alias)).toEqual(['D1', 'S1', 'C1', 'X1']);
112
+ });
113
+
114
+ it('appends only new items in draft order', () => {
115
+ const items = [mk(1, 'X1'), mk(2, 'C1'), mk(3, 'S1')];
116
+ const preBatchItemIds = new Set([1]);
117
+ reorderSessionItemsByDrafts(
118
+ items,
119
+ [
120
+ { id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
121
+ { id: 3, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' }
122
+ ],
123
+ preBatchItemIds
124
+ );
125
+ expect(items.map(item => item.alias)).toEqual(['X1', 'C1', 'S1']);
126
+ });
127
+
128
+ it('does not move existing items on update-only patch', () => {
129
+ const items = [mk(1, 'X1'), mk(2, 'D1')];
130
+ const preBatchItemIds = new Set(items.map(item => item.id));
131
+ reorderSessionItemsByDrafts(
132
+ items,
133
+ [{ id: 2, alias: 'D1', cstType: CstType.TERM, definitionFormal: '1+2' }],
134
+ preBatchItemIds
135
+ );
136
+ expect(items.map(item => item.alias)).toEqual(['X1', 'D1']);
137
+ });
138
+
139
+ it('keeps edited items in place when mixed with inserts', () => {
140
+ const items = [mk(1, 'X1'), mk(2, 'C1'), mk(3, 'S1'), mk(4, 'D1')];
141
+ const preBatchItemIds = new Set([1, 2, 3]);
142
+ reorderSessionItemsByDrafts(
143
+ items,
144
+ [
145
+ { id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '1' },
146
+ { id: 4, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' }
147
+ ],
148
+ preBatchItemIds
149
+ );
150
+ expect(items.map(item => item.alias)).toEqual(['X1', 'C1', 'S1', 'D1']);
27
151
  });
28
152
  });
@@ -45,3 +45,41 @@ export function orderDrafts(sessionItems: ConstituentaState[], drafts: Constitue
45
45
  const orderedIds = [...topoIds, ...missing];
46
46
  return orderedIds.map(id => drafts.find(draft => draft.id === id)!).filter(Boolean);
47
47
  }
48
+
49
+ /**
50
+ * Restore declaration order in session items after a batch apply.
51
+ * Topological apply order is only needed for analysis; Portal JSON uses array order.
52
+ */
53
+ export function reorderSessionItemsByDrafts(
54
+ items: ConstituentaState[],
55
+ drafts: ConstituentaDraft[],
56
+ preBatchItemIds: ReadonlySet<number>
57
+ ): void {
58
+ if (drafts.length === 0 || items.length === 0) {
59
+ return;
60
+ }
61
+
62
+ const draftIds = drafts.map(draft => draft.id);
63
+ const draftIdSet = new Set(draftIds);
64
+ const mentioned = items.filter(item => draftIdSet.has(item.id));
65
+ if (mentioned.length === 0) {
66
+ return;
67
+ }
68
+
69
+ const unmentioned = items.filter(item => !draftIdSet.has(item.id));
70
+ if (unmentioned.length === 0) {
71
+ const byId = new Map(items.map(item => [item.id, item]));
72
+ items.splice(0, items.length, ...draftIds.map(id => byId.get(id)!));
73
+ return;
74
+ }
75
+
76
+ const newDrafts = drafts.filter(draft => !preBatchItemIds.has(draft.id));
77
+ if (newDrafts.length === 0) {
78
+ return;
79
+ }
80
+
81
+ const newIds = new Set(newDrafts.map(draft => draft.id));
82
+ const kept = items.filter(item => !newIds.has(item.id));
83
+ const byId = new Map(items.map(item => [item.id, item]));
84
+ items.splice(0, items.length, ...kept, ...newDrafts.map(draft => byId.get(draft.id)!));
85
+ }
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { SessionPersistence, type PersistedSessionEnvelope } from './persistence';
8
+
9
+ const envelope: PersistedSessionEnvelope = {
10
+ state: {
11
+ sessionId: 'safe-id',
12
+ alias: '',
13
+ title: '',
14
+ comment: '',
15
+ createdAt: '2026-01-01T00:00:00.000Z',
16
+ updatedAt: '2026-01-01T00:00:00.000Z',
17
+ revisions: [],
18
+ items: [],
19
+ model: { items: [] }
20
+ },
21
+ diagnostics: []
22
+ };
23
+
24
+ describe('SessionPersistence', () => {
25
+ const dirs: string[] = [];
26
+
27
+ afterEach(() => {
28
+ for (const dir of dirs.splice(0)) {
29
+ fs.rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ function createPersistence(): SessionPersistence {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rstool-session-'));
35
+ dirs.push(dir);
36
+ return new SessionPersistence(dir);
37
+ }
38
+
39
+ it('persists a safe session id under the configured directory', () => {
40
+ const persistence = createPersistence();
41
+ const sessionId = 'a03eec4c-6519-4088-9d8c-0a9c11611583';
42
+
43
+ persistence.save(sessionId, envelope);
44
+
45
+ expect(persistence.load(sessionId)).toEqual(envelope);
46
+ persistence.delete(sessionId);
47
+ expect(persistence.load(sessionId)).toBeNull();
48
+ });
49
+
50
+ it.each(['../escape', 'foo/bar', 'foo\\bar', '..', 'safe/../escape'])(
51
+ 'rejects unsafe session id %j on save',
52
+ unsafeId => {
53
+ const persistence = createPersistence();
54
+ expect(() => persistence.save(unsafeId, envelope)).toThrow(/Invalid session ID/);
55
+ }
56
+ );
57
+
58
+ it.each(['../escape', 'foo/bar', 'foo\\bar', '..', 'safe/../escape'])(
59
+ 'rejects unsafe session id %j on load and delete',
60
+ unsafeId => {
61
+ const persistence = createPersistence();
62
+ expect(() => persistence.load(unsafeId)).toThrow(/Invalid session ID/);
63
+ expect(() => persistence.delete(unsafeId)).toThrow(/Invalid session ID/);
64
+ }
65
+ );
66
+ });
@@ -9,6 +9,13 @@ export interface PersistedSessionEnvelope {
9
9
  }
10
10
 
11
11
  const CURRENT_SESSION_FILE = '_current.json';
12
+ const UNSAFE_SESSION_ID = /[/\\]|\.\./;
13
+
14
+ function assertSafeSessionId(sessionId: string): void {
15
+ if (!sessionId || UNSAFE_SESSION_ID.test(sessionId)) {
16
+ throw new Error(`Invalid session ID: ${sessionId}`);
17
+ }
18
+ }
12
19
 
13
20
  export class SessionPersistence {
14
21
  private readonly dir: string;
@@ -51,6 +58,12 @@ export class SessionPersistence {
51
58
  }
52
59
 
53
60
  private filePath(sessionId: string): string {
54
- return path.join(this.dir, `${sessionId}.json`);
61
+ assertSafeSessionId(sessionId);
62
+ const file = path.resolve(this.dir, `${sessionId}.json`);
63
+ const relative = path.relative(path.resolve(this.dir), file);
64
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
65
+ throw new Error(`Invalid session ID: ${sessionId}`);
66
+ }
67
+ return file;
55
68
  }
56
69
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import {
4
+ CONTRACT_VERSION,
4
5
  type DiagnosticRecord,
5
6
  type ListDiagnosticsFilters,
6
7
  type SessionHandle,
@@ -48,7 +49,7 @@ export class SessionStore {
48
49
  this.persist(sessionId, envelope);
49
50
  return {
50
51
  sessionId,
51
- contractVersion: contractVersion ?? '1.0.0'
52
+ contractVersion: contractVersion ?? CONTRACT_VERSION
52
53
  };
53
54
  }
54
55
 
@@ -61,7 +62,13 @@ export class SessionStore {
61
62
  }
62
63
 
63
64
  public has(sessionId: string): boolean {
64
- return this.sessions.has(sessionId) || this.persistence?.load(sessionId) !== null;
65
+ if (this.sessions.has(sessionId)) {
66
+ return true;
67
+ }
68
+ if (!this.persistence) {
69
+ return false;
70
+ }
71
+ return this.persistence.load(sessionId) !== null;
65
72
  }
66
73
 
67
74
  public replaceState(sessionId: string, nextState: SessionState): void {
@@ -108,7 +115,7 @@ export class SessionStore {
108
115
 
109
116
  public listDiagnostics(sessionId: string, filters?: ListDiagnosticsFilters): DiagnosticRecord[] {
110
117
  const found = this.get(sessionId);
111
- if (!filters?.constituentId) {
118
+ if (filters?.constituentId === undefined) {
112
119
  return [...found.diagnostics];
113
120
  }
114
121
  return found.diagnostics.filter(record => record.constituentId === filters.constituentId);
@@ -0,0 +1,58 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { CstType, EvalStatus, RSToolWrapperClient } from '../index';
6
+
7
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
8
+
9
+ describe('RSToolWrapperClient integration', () => {
10
+ it('runs createSession, patch, model, and evaluate over stdio', async () => {
11
+ const client = new RSToolWrapperClient({
12
+ command: 'npx',
13
+ args: ['tsx', resolve(packageRoot, 'src/wrapper/stdio-wrapper.ts')],
14
+ cwd: packageRoot,
15
+ shell: true
16
+ });
17
+
18
+ try {
19
+ await client.waitUntilReady();
20
+
21
+ const methods = await client.call<string[]>('methods');
22
+ expect(methods).toContain('applySchemaPatch');
23
+
24
+ const session = await client.call<{ sessionId: string; contractVersion: string }>('createSession', {
25
+ initial: { title: 'Wrapper test' }
26
+ });
27
+ expect(session.sessionId).toBeTruthy();
28
+
29
+ await client.call('applySchemaPatch', {
30
+ sessionId: session.sessionId,
31
+ items: [{ alias: 'X1' }, { alias: 'D1', definitionFormal: '1+2' }]
32
+ });
33
+
34
+ await client.call('setModelValues', {
35
+ sessionId: session.sessionId,
36
+ set: [{ target: 1, value: { 0: 'zero' } }]
37
+ });
38
+
39
+ const evalResult = await client.call<{ success: boolean; value: number; status: number }>('evaluate', {
40
+ sessionId: session.sessionId,
41
+ constituentId: 2
42
+ });
43
+ expect(evalResult.success).toBe(true);
44
+ expect(evalResult.value).toBe(3);
45
+ expect(evalResult.status).toBe(EvalStatus.HAS_DATA);
46
+
47
+ const analysis = await client.call<{ success: boolean; diagnostics: unknown[] }>('analyzeExpression', {
48
+ sessionId: session.sessionId,
49
+ expression: '(',
50
+ cstType: CstType.TERM
51
+ });
52
+ expect(analysis.success).toBe(false);
53
+ expect(analysis.diagnostics.length).toBeGreaterThan(0);
54
+ } finally {
55
+ await client.close();
56
+ }
57
+ }, 30_000);
58
+ });
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { CstType, CONTRACT_VERSION, RSToolAgent } from '../models';
5
+ import { handleStdioRequest, STDIO_METHODS } from './stdio-handler';
6
+
7
+ describe('handleStdioRequest', () => {
8
+ const tool = new RSToolAgent();
9
+
10
+ it('responds to ping with contract version', async () => {
11
+ const response = await handleStdioRequest(tool, { id: 1, method: 'ping' });
12
+ expect(response).toEqual({
13
+ id: 1,
14
+ ok: true,
15
+ result: { pong: true, contractVersion: CONTRACT_VERSION }
16
+ });
17
+ });
18
+
19
+ it('lists contract methods', async () => {
20
+ const response = await handleStdioRequest(tool, { id: 2, method: 'methods' });
21
+ expect(response.ok).toBe(true);
22
+ expect(response.result).toEqual(STDIO_METHODS);
23
+ });
24
+
25
+ it('applies schema patch through the wrapper', async () => {
26
+ const response = await handleStdioRequest(tool, {
27
+ id: 3,
28
+ method: 'applySchemaPatch',
29
+ params: { items: [{ alias: 'X1' }] }
30
+ });
31
+ expect(response.ok).toBe(true);
32
+ expect((response.result as { success: boolean }).success).toBe(true);
33
+ });
34
+
35
+ it('evaluates a constituent', async () => {
36
+ const sessionTool = new RSToolAgent();
37
+ await handleStdioRequest(sessionTool, {
38
+ id: 10,
39
+ method: 'applySchemaPatch',
40
+ params: {
41
+ items: [{ alias: 'X1' }, { alias: 'D1', definitionFormal: '1+2' }]
42
+ }
43
+ });
44
+ const response = await handleStdioRequest(sessionTool, {
45
+ id: 11,
46
+ method: 'evaluate',
47
+ params: { constituentId: 2 }
48
+ });
49
+ expect(response.ok).toBe(true);
50
+ expect((response.result as { value: number }).value).toBe(3);
51
+ });
52
+
53
+ it('returns METHOD_NOT_FOUND for unknown methods', async () => {
54
+ const response = await handleStdioRequest(tool, { id: 4, method: 'notReal' });
55
+ expect(response.ok).toBe(false);
56
+ expect(response.error?.code).toBe('METHOD_NOT_FOUND');
57
+ });
58
+
59
+ it('returns INTERNAL_ERROR when setCurrentSession lacks sessionId', async () => {
60
+ const response = await handleStdioRequest(tool, { id: 5, method: 'setCurrentSession', params: {} });
61
+ expect(response.ok).toBe(false);
62
+ expect(response.error?.code).toBe('INTERNAL_ERROR');
63
+ expect(response.error?.message).toMatch(/sessionId/);
64
+ });
65
+
66
+ it('returns INTERNAL_ERROR for unknown session id', async () => {
67
+ const sessionTool = new RSToolAgent();
68
+ const response = await handleStdioRequest(sessionTool, {
69
+ id: 6,
70
+ method: 'setCurrentSession',
71
+ params: { sessionId: randomUUID() }
72
+ });
73
+ expect(response.ok).toBe(false);
74
+ expect(response.error?.code).toBe('INTERNAL_ERROR');
75
+ expect(response.error?.message).toMatch(/Unknown session/);
76
+ });
77
+
78
+ it('filters diagnostics by constituentId param', async () => {
79
+ const sessionTool = new RSToolAgent();
80
+ const created = await handleStdioRequest(sessionTool, { id: 20, method: 'createSession' });
81
+ const sessionId = (created.result as { sessionId: string }).sessionId;
82
+ await handleStdioRequest(sessionTool, {
83
+ id: 21,
84
+ method: 'applySchemaPatch',
85
+ params: {
86
+ sessionId,
87
+ mode: 'best_effort',
88
+ items: [{ alias: 'X1', cstType: CstType.BASE, definitionFormal: 'Z' }]
89
+ }
90
+ });
91
+
92
+ const all = await handleStdioRequest(sessionTool, { id: 22, method: 'listDiagnostics', params: { sessionId } });
93
+ const filtered = await handleStdioRequest(sessionTool, {
94
+ id: 23,
95
+ method: 'listDiagnostics',
96
+ params: { sessionId, constituentId: 1 }
97
+ });
98
+ expect((all.result as unknown[]).length).toBeGreaterThan(0);
99
+ expect(filtered.result).toEqual(all.result);
100
+ });
101
+ });