@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.
- package/README.md +22 -4
- package/dist/{agent-workflow-D-PSIb-m.d.ts → agent-workflow-Gk0Vfnv1.d.ts} +3 -9
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -2
- package/dist/models/agent-workflow.d.ts +2 -2
- package/dist/models/index.d.ts +3 -3
- package/dist/models/index.js +1 -1
- package/dist/models/rstool-agent.d.ts +1 -1
- package/dist/models/rstool-agent.js +1 -1
- package/dist/models/tool-contract.d.ts +1 -1
- package/dist/{rstool-agent-kijHA9ML.js → models-9G6ur_pg.js} +242 -34
- package/dist/models-9G6ur_pg.js.map +1 -0
- package/dist/{rstool-agent-_8bplZnb.d.ts → rstool-agent-D2cQze_b.d.ts} +3 -3
- package/dist/session/session-store.js +1 -1
- package/dist/{tool-contract-5_Q44DGE.d.ts → tool-contract-0uRGhEfW.d.ts} +2 -2
- package/dist/wrapper/stdio-wrapper.js +16 -14
- package/dist/wrapper/stdio-wrapper.js.map +1 -1
- package/docs/DIAGNOSTICS.md +23 -14
- package/examples/build-chocolate-nim-rsform.ts +150 -209
- package/examples/kinship/build-rsmodel.ts +5 -3
- package/examples/sample/build-rsform.ts +7 -9
- package/examples/sample/rsform-session.json +31 -31
- package/package.json +6 -4
- package/src/mappers/portal-adapter.ts +14 -8
- package/src/models/agent-workflow.ts +1 -13
- package/src/models/import-detect.test.ts +66 -0
- package/src/models/import-detect.ts +6 -3
- package/src/models/portal-json.test.ts +38 -0
- package/src/models/portal-json.ts +11 -2
- package/src/models/rstool-agent.test.ts +402 -3
- package/src/models/rstool-agent.ts +61 -17
- package/src/session/batch-apply.test.ts +132 -8
- package/src/session/batch-apply.ts +38 -0
- package/src/session/persistence.test.ts +66 -0
- package/src/session/persistence.ts +14 -1
- package/src/session/session-store.ts +10 -3
- package/src/wrapper/client.test.ts +58 -0
- package/src/wrapper/stdio-handler.test.ts +101 -0
- package/src/wrapper/stdio-handler.ts +195 -0
- package/src/wrapper/stdio-wrapper.ts +2 -195
- package/dist/rstool-agent-kijHA9ML.js.map +0 -1
- package/dist/session-store-C3jyOSqI.js +0 -142
- 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
|
|
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
|
-
|
|
250
|
+
const snapshot = this.sessions.snapshot(id);
|
|
245
251
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
state = { ...state, model };
|
|
249
|
-
this.sessions.replaceState(id, state);
|
|
250
|
-
}
|
|
252
|
+
try {
|
|
253
|
+
let state = this.sessions.get(id).state;
|
|
251
254
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
12
|
-
alias: '
|
|
13
|
-
cstType: CstType.
|
|
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:
|
|
23
|
-
{ id: 2, alias: '
|
|
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(['
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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 (
|
|
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
|
+
});
|