@rsconcept/rstool 1.0.0 → 1.0.1
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-Bw6Uum8i.js} +243 -34
- package/dist/models-Bw6Uum8i.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/examples/build-chocolate-nim-rsform.ts +2 -56
- 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 +3 -1
- 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 +60 -17
- package/src/session/batch-apply.test.ts +103 -8
- package/src/session/batch-apply.ts +35 -0
- package/src/session/persistence.test.ts +63 -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 */
|
|
@@ -325,6 +338,10 @@ export class RSToolAgent implements RSToolAgentContract {
|
|
|
325
338
|
}
|
|
326
339
|
}
|
|
327
340
|
|
|
341
|
+
const envelope = this.sessions.get(id);
|
|
342
|
+
reorderSessionItemsByDrafts(envelope.state.items, input.drafts);
|
|
343
|
+
this.sessions.replaceState(id, envelope.state);
|
|
344
|
+
|
|
328
345
|
return {
|
|
329
346
|
success: failed.length === 0,
|
|
330
347
|
applied,
|
|
@@ -424,11 +441,37 @@ export class RSToolAgent implements RSToolAgentContract {
|
|
|
424
441
|
private resolveAgentPatches(sessionId: string, patches: AgentConstituentaPatch[]): ConstituentaDraft[] {
|
|
425
442
|
const items = this.sessions.get(sessionId).state.items;
|
|
426
443
|
const existingByAlias = new Map(items.map(item => [item.alias, item]));
|
|
444
|
+
const usedIds = new Set(items.map(item => item.id));
|
|
427
445
|
let nextId = items.reduce((max, item) => Math.max(max, item.id), 0) + 1;
|
|
428
446
|
|
|
447
|
+
const reserveId = (id: number): void => {
|
|
448
|
+
usedIds.add(id);
|
|
449
|
+
if (id >= nextId) {
|
|
450
|
+
nextId = id + 1;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const allocateId = (): number => {
|
|
455
|
+
while (usedIds.has(nextId)) {
|
|
456
|
+
nextId += 1;
|
|
457
|
+
}
|
|
458
|
+
const id = nextId;
|
|
459
|
+
nextId += 1;
|
|
460
|
+
usedIds.add(id);
|
|
461
|
+
return id;
|
|
462
|
+
};
|
|
463
|
+
|
|
429
464
|
return patches.map(patch => {
|
|
430
465
|
const existing = existingByAlias.get(patch.alias);
|
|
431
|
-
|
|
466
|
+
let id: number;
|
|
467
|
+
if (patch.id !== undefined) {
|
|
468
|
+
id = patch.id;
|
|
469
|
+
reserveId(id);
|
|
470
|
+
} else if (existing !== undefined) {
|
|
471
|
+
id = existing.id;
|
|
472
|
+
} else {
|
|
473
|
+
id = allocateId();
|
|
474
|
+
}
|
|
432
475
|
const draft = {
|
|
433
476
|
id,
|
|
434
477
|
alias: patch.alias,
|
|
@@ -1,28 +1,123 @@
|
|
|
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],
|
|
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],
|
|
9
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
|
+
reorderSessionItemsByDrafts(items, [
|
|
101
|
+
{ id: 1, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
|
|
102
|
+
{ id: 2, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' },
|
|
103
|
+
{ id: 3, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
|
|
104
|
+
{ id: 4, alias: 'X1', cstType: CstType.BASE, definitionFormal: '' }
|
|
105
|
+
]);
|
|
106
|
+
expect(items.map(item => item.alias)).toEqual(['D1', 'S1', 'C1', 'X1']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('appends only new items in draft order', () => {
|
|
110
|
+
const items = [mk(1, 'X1'), mk(3, 'S1'), mk(2, 'C1')];
|
|
111
|
+
reorderSessionItemsByDrafts(items, [
|
|
112
|
+
{ id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
|
|
113
|
+
{ id: 3, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' }
|
|
114
|
+
]);
|
|
115
|
+
expect(items.map(item => item.alias)).toEqual(['X1', 'C1', 'S1']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not move existing items on update-only patch', () => {
|
|
119
|
+
const items = [mk(1, 'X1'), mk(2, 'D1')];
|
|
120
|
+
reorderSessionItemsByDrafts(items, [{ id: 2, alias: 'D1', cstType: CstType.TERM, definitionFormal: '1+2' }]);
|
|
121
|
+
expect(items.map(item => item.alias)).toEqual(['X1', 'D1']);
|
|
27
122
|
});
|
|
28
123
|
});
|
|
@@ -45,3 +45,38 @@ 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(items: ConstituentaState[], drafts: ConstituentaDraft[]): void {
|
|
54
|
+
if (drafts.length === 0 || items.length === 0) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const draftIds = drafts.map(draft => draft.id);
|
|
59
|
+
const draftIdSet = new Set(draftIds);
|
|
60
|
+
const mentioned = items.filter(item => draftIdSet.has(item.id));
|
|
61
|
+
if (mentioned.length === 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const unmentioned = items.filter(item => !draftIdSet.has(item.id));
|
|
66
|
+
if (unmentioned.length === 0) {
|
|
67
|
+
const byId = new Map(items.map(item => [item.id, item]));
|
|
68
|
+
items.splice(0, items.length, ...draftIds.map(id => byId.get(id)!));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const existingIds = new Set(unmentioned.map(item => item.id));
|
|
73
|
+
const newDrafts = drafts.filter(draft => !existingIds.has(draft.id));
|
|
74
|
+
if (newDrafts.length === 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const newIds = new Set(newDrafts.map(draft => draft.id));
|
|
79
|
+
const kept = items.filter(item => !newIds.has(item.id));
|
|
80
|
+
const byId = new Map(items.map(item => [item.id, item]));
|
|
81
|
+
items.splice(0, items.length, ...kept, ...newDrafts.map(draft => byId.get(draft.id)!));
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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', '..'])('rejects unsafe session id %j on load and delete', unsafeId => {
|
|
59
|
+
const persistence = createPersistence();
|
|
60
|
+
expect(() => persistence.load(unsafeId)).toThrow(/Invalid session ID/);
|
|
61
|
+
expect(() => persistence.delete(unsafeId)).toThrow(/Invalid session ID/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -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
|
+
});
|