sap-ai-dev-toolkit 0.3.0 → 0.3.3
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/.github/agents/abap-developer.agent.md +9 -7
- package/.github/agents/rap-service-developer.agent.md +10 -8
- package/.github/agents/sap-solution-architect.agent.md +2 -2
- package/.github/skills/abap-development/SKILL.md +6 -4
- package/.github/skills/rap-development/SKILL.md +6 -4
- package/.github/skills/rap-service-delivery/SKILL.md +7 -5
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +5 -4
- package/README.md +19 -5
- package/package.json +2 -1
- package/scripts/install-local.mjs +115 -0
- package/src/bas-destination-relay.mjs +271 -0
- package/src/bas-discovery.mjs +4 -0
- package/src/credentials-store.mjs +114 -0
- package/src/launcher.mjs +44 -2
- package/src/mcp-proxy.mjs +120 -32
- package/src/setup.mjs +90 -2
- package/src/terminal-ui.mjs +84 -0
- package/test/bas-destination-relay.test.mjs +188 -0
- package/test/copilot-content.test.mjs +41 -0
- package/test/credentials-store.test.mjs +156 -0
- package/test/discovery.test.mjs +1 -1
- package/test/mcp-proxy.test.mjs +59 -3
|
@@ -56,6 +56,18 @@ function assertValidationPolicy(markdown, path) {
|
|
|
56
56
|
assert.match(markdown, /report|Record|document/i, `${path} must require observed outcome reporting`);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function assertPlanFirstPolicy(markdown, path) {
|
|
60
|
+
assert.match(markdown, /[Pp]lan before implementing|[Pp]lanning phase before implementation/, `${path} must require a plan to be presented before implementation starts`);
|
|
61
|
+
assert.match(markdown, /wait for explicit (?:user )?approval|no SAP state-changing package proceeds before the user approves/i, `${path} must gate SAP state-changing implementation on explicit approval`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertLocalFirstPolicy(markdown, path) {
|
|
65
|
+
assert.match(markdown, /abapGit[- ]serialized|abapGit-style/, `${path} must require local abapGit-style source files`);
|
|
66
|
+
assert.match(markdown, /object\.type\.extension/, `${path} must document the object.type.extension file naming`);
|
|
67
|
+
assert.match(markdown, /LintABAP/, `${path} must require local linting with LintABAP`);
|
|
68
|
+
assert.match(markdown, /before (?:they are )?sent to the SAP system|before sending to SAP|check and lint before sending/i, `${path} must require local checks before the SAP transfer`);
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
function assertNoUnsafeTransportClaims(markdown, path) {
|
|
60
72
|
const releaseMentions = markdown.match(/ReleaseTransport|release\/deletion|release and deletion|release is unavailable|release action/gi) || [];
|
|
61
73
|
assert.ok(releaseMentions.length > 0 || !/transport/i.test(markdown), `${path} transport guidance should explicitly handle release limitations when transport is discussed`);
|
|
@@ -88,6 +100,8 @@ test('ABAP Developer agent has valid metadata and invokes the packaged skills',
|
|
|
88
100
|
}
|
|
89
101
|
assertMentionsLiveToolDiscovery(markdown, agentPath);
|
|
90
102
|
assertValidationPolicy(markdown, agentPath);
|
|
103
|
+
assertPlanFirstPolicy(markdown, agentPath);
|
|
104
|
+
assertLocalFirstPolicy(markdown, agentPath);
|
|
91
105
|
assertNoUnsafeTransportClaims(markdown, agentPath);
|
|
92
106
|
});
|
|
93
107
|
|
|
@@ -108,6 +122,24 @@ test('SAP Solution Architect agent has valid metadata and planning/handoff polic
|
|
|
108
122
|
assert.match(markdown, /RAP Service Developer/i);
|
|
109
123
|
assert.match(markdown, /ABAP Runtime Debugger/i);
|
|
110
124
|
assert.match(markdown, /SDLC|lifecycle/i);
|
|
125
|
+
assertPlanFirstPolicy(markdown, path);
|
|
126
|
+
assertLocalFirstPolicy(markdown, path);
|
|
127
|
+
assertNoUnsafeTransportClaims(markdown, path);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('RAP Service Developer agent has valid metadata and plan-first local-first policy', async () => {
|
|
131
|
+
const path = join(agentsRoot, 'rap-service-developer.agent.md');
|
|
132
|
+
const markdown = await readMarkdown(path);
|
|
133
|
+
const metadata = parseFrontmatter(markdown, path);
|
|
134
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
135
|
+
assert.equal(metadata.name, 'RAP Service Developer');
|
|
136
|
+
assert.equal(metadata.target, 'vscode');
|
|
137
|
+
assert.equal(metadata['user-invocable'], 'true');
|
|
138
|
+
assert.match(metadata.description, /RAP/i);
|
|
139
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
140
|
+
assertValidationPolicy(markdown, path);
|
|
141
|
+
assertPlanFirstPolicy(markdown, path);
|
|
142
|
+
assertLocalFirstPolicy(markdown, path);
|
|
111
143
|
assertNoUnsafeTransportClaims(markdown, path);
|
|
112
144
|
});
|
|
113
145
|
|
|
@@ -134,6 +166,15 @@ test('implementation-oriented skills require tests, runtime evidence, activation
|
|
|
134
166
|
}
|
|
135
167
|
});
|
|
136
168
|
|
|
169
|
+
test('implementation and delivery skills require planning first and local-first authoring', async () => {
|
|
170
|
+
for (const skill of ['abap-development', 'rap-development', 'rap-service-delivery', 'sap-sdlc-orchestration']) {
|
|
171
|
+
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
172
|
+
const markdown = await readMarkdown(path);
|
|
173
|
+
assertPlanFirstPolicy(markdown, path);
|
|
174
|
+
assertLocalFirstPolicy(markdown, path);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
137
178
|
test('debugging skill requires evidence before fixes and bounded debugger use', async () => {
|
|
138
179
|
const path = join(skillsRoot, 'abap-debugging', 'SKILL.md');
|
|
139
180
|
const markdown = await readMarkdown(path);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { request as httpRequest } from 'node:http';
|
|
8
|
+
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
9
|
+
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
|
|
10
|
+
|
|
11
|
+
const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
12
|
+
|
|
13
|
+
// SAP-like backend that requires Basic auth AND token+cookie pairing —
|
|
14
|
+
// the semantics that only work when the relay connects directly.
|
|
15
|
+
function authedBackend({ expectedUser = 'DEVELOPER', expectedPassword = 'secret123' } = {}) {
|
|
16
|
+
const state = { sessionCounter: 0, requests: [] };
|
|
17
|
+
const server = http.createServer((req, res) => {
|
|
18
|
+
state.requests.push({ method: req.method, headers: { ...req.headers } });
|
|
19
|
+
const authorization = String(req.headers.authorization || '');
|
|
20
|
+
const basic = authorization.replace(/^Basic /i, '');
|
|
21
|
+
const [user, password] = Buffer.from(basic, 'base64').toString().split(':');
|
|
22
|
+
if (user !== expectedUser || password !== expectedPassword) {
|
|
23
|
+
res.writeHead(401, { 'www-authenticate': 'Basic' });
|
|
24
|
+
res.end('credentials required');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
28
|
+
state.sessionCounter += 1;
|
|
29
|
+
res.writeHead(200, {
|
|
30
|
+
'x-csrf-token': `token-${state.sessionCounter}`,
|
|
31
|
+
'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`]
|
|
32
|
+
});
|
|
33
|
+
res.end('<ok/>');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (UNSAFE_METHODS.includes(req.method)) {
|
|
37
|
+
const match = String(req.headers['x-csrf-token'] || '').match(/^token-(\d+)$/);
|
|
38
|
+
const valid = match && String(req.headers.cookie || '').includes(`sap-session-${match[1]}=`);
|
|
39
|
+
if (!valid) {
|
|
40
|
+
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
41
|
+
res.end('CSRF token validation failed');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
res.writeHead(201);
|
|
45
|
+
res.end('<created/>');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
res.writeHead(200);
|
|
49
|
+
res.end('<read/>');
|
|
50
|
+
});
|
|
51
|
+
return { state, server };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function relayFetch(relayUrl, path, options = {}) {
|
|
55
|
+
const target = new URL(path, relayUrl);
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
|
|
58
|
+
let body = '';
|
|
59
|
+
response.setEncoding('utf8');
|
|
60
|
+
response.on('data', chunk => { body += chunk; });
|
|
61
|
+
response.on('end', () => resolve({ status: response.statusCode, body }));
|
|
62
|
+
});
|
|
63
|
+
req.on('error', reject);
|
|
64
|
+
if (options.body) req.write(options.body);
|
|
65
|
+
req.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('credentials file resolves next to the MCP config and stores entries with 0600', async t => {
|
|
70
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-creds-'));
|
|
71
|
+
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
72
|
+
const mcpConfig = join(directory, 'mcp.json');
|
|
73
|
+
const path = await resolveCredentialsPath({ HOME: '/nonexistent' }, mcpConfig);
|
|
74
|
+
assert.equal(path, join(directory, 'sap-ai-dev-toolkit-credentials.json'));
|
|
75
|
+
|
|
76
|
+
await storeDestinationCredentials(path, 'S4H', { host: 'https://backend.example:44300', user: 'DEVELOPER', password: 'secret123' });
|
|
77
|
+
const stored = await readCredentials(path);
|
|
78
|
+
assert.equal(stored.destinations.S4H.user, 'DEVELOPER');
|
|
79
|
+
assert.equal(stored.destinations.S4H.host, 'https://backend.example:44300');
|
|
80
|
+
const permissions = (await stat(path)).mode & 0o777;
|
|
81
|
+
assert.equal(permissions, 0o600, 'credentials file must be owner-only');
|
|
82
|
+
|
|
83
|
+
const raw = JSON.parse(await readFile(path, 'utf8'));
|
|
84
|
+
assert.equal(raw.version, 1);
|
|
85
|
+
assert.ok(Object.hasOwn(raw.destinations, 'S4H'));
|
|
86
|
+
|
|
87
|
+
const { removed } = await removeDestinationCredentials(path, ['S4H']);
|
|
88
|
+
assert.deepEqual(removed, ['S4H']);
|
|
89
|
+
assert.equal((await readCredentials(path)).destinations.S4H, undefined);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('credentials file with malformed entries keeps only complete records', async t => {
|
|
93
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-creds-bad-'));
|
|
94
|
+
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
95
|
+
const path = join(directory, 'sap-ai-dev-toolkit-credentials.json');
|
|
96
|
+
await writeFile(path, JSON.stringify({
|
|
97
|
+
version: 1,
|
|
98
|
+
destinations: {
|
|
99
|
+
GOOD: { host: 'https://good.example', user: 'u', password: 'p', updatedAt: '2026-01-01T00:00:00.000Z' },
|
|
100
|
+
NO_PASSWORD: { host: 'https://bad.example', user: 'u' },
|
|
101
|
+
NOT_OBJECT: 'nope'
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
const stored = await readCredentials(path);
|
|
105
|
+
assert.deepEqual(Object.keys(stored.destinations), ['GOOD']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('relay direct connect sends Basic auth and CSRF pairing succeeds', async t => {
|
|
109
|
+
const { state, server } = authedBackend();
|
|
110
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
111
|
+
const backendUrl = `http://127.0.0.1:${server.address().port}`;
|
|
112
|
+
const relay = createBasDestinationRelay(
|
|
113
|
+
{
|
|
114
|
+
name: 'S4H',
|
|
115
|
+
url: 'http://S4H.dest',
|
|
116
|
+
authentication: 'BasicAuthentication',
|
|
117
|
+
credentials: { host: backendUrl, user: 'DEVELOPER', password: 'secret123' }
|
|
118
|
+
},
|
|
119
|
+
{ env: { HTTP_PROXY: '' }, log: () => {} }
|
|
120
|
+
);
|
|
121
|
+
const relayUrl = await relay.ready;
|
|
122
|
+
t.after(async () => { await relay.close(); await new Promise(resolve => server.close(resolve)); });
|
|
123
|
+
try {
|
|
124
|
+
const read = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
125
|
+
assert.equal(read.status, 200, 'authenticated read must succeed');
|
|
126
|
+
const write = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<q/>' });
|
|
127
|
+
assert.equal(write.status, 201, `CSRF-protected write must succeed, got ${write.status}: ${write.body}`);
|
|
128
|
+
const post = state.requests.find(request => request.method === 'POST');
|
|
129
|
+
assert.match(post.headers.authorization, /^Basic /);
|
|
130
|
+
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
131
|
+
assert.equal(relay.stats.direct, true);
|
|
132
|
+
} finally {
|
|
133
|
+
void relayUrl;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('relay direct connect with wrong credentials surfaces 401 without retries', async t => {
|
|
138
|
+
const { state, server } = authedBackend();
|
|
139
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
140
|
+
const backendUrl = `http://127.0.0.1:${server.address().port}`;
|
|
141
|
+
const relay = createBasDestinationRelay(
|
|
142
|
+
{
|
|
143
|
+
name: 'S4H',
|
|
144
|
+
url: 'http://S4H.dest',
|
|
145
|
+
authentication: 'BasicAuthentication',
|
|
146
|
+
credentials: { host: backendUrl, user: 'DEVELOPER', password: 'wrong' }
|
|
147
|
+
},
|
|
148
|
+
{ env: { HTTP_PROXY: '' }, log: () => {} }
|
|
149
|
+
);
|
|
150
|
+
const relayUrl = await relay.ready;
|
|
151
|
+
t.after(async () => { await relay.close(); await new Promise(resolve => server.close(resolve)); });
|
|
152
|
+
const write = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<q/>' });
|
|
153
|
+
assert.equal(write.status, 401);
|
|
154
|
+
assert.equal(relay.stats.csrfRetries, 0, '401 without a session must not trigger CSRF retries');
|
|
155
|
+
void state;
|
|
156
|
+
});
|
package/test/discovery.test.mjs
CHANGED
|
@@ -10,7 +10,7 @@ test('normalizes destination metadata case-insensitively', () => {
|
|
|
10
10
|
});
|
|
11
11
|
assert.deepEqual(destination, {
|
|
12
12
|
name: 'DEV-ABAP', authentication: 'PrincipalPropagation', client: '200',
|
|
13
|
-
url: 'http://DEV-ABAP.dest', rawKeys: ['Name', 'Properties']
|
|
13
|
+
url: 'http://DEV-ABAP.dest', backendUrl: 'https://backend.example', proxyType: null, rawKeys: ['Name', 'Properties']
|
|
14
14
|
});
|
|
15
15
|
});
|
|
16
16
|
|
package/test/mcp-proxy.test.mjs
CHANGED
|
@@ -47,6 +47,57 @@ test('uses authentication-specific arguments for Cloud Foundry children', () =>
|
|
|
47
47
|
assert.equal(childArguments(principal, {}).includes('--proxy-auth'), true);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test('self-heals BAS destinations through a local destination relay without credentials', async t => {
|
|
51
|
+
const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
|
|
52
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-relay-'));
|
|
53
|
+
const log = join(directory, 'children.log');
|
|
54
|
+
const logs = [];
|
|
55
|
+
const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: message => logs.push(message) });
|
|
56
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
57
|
+
proxy.start();
|
|
58
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
59
|
+
const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
|
|
60
|
+
assert.equal(event.argv.includes('--proxy-auth'), true);
|
|
61
|
+
assert.match(event.argv[event.argv.indexOf('--url') + 1], /^http:\/\/127\.0\.0\.1:\d+$/);
|
|
62
|
+
assert.equal(event.env.user, undefined);
|
|
63
|
+
assert.equal(event.env.password, undefined);
|
|
64
|
+
assert.ok(logs.some(message => message.includes('BAS destination relay enabled')));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('self-heals BAS relay children with loopback NO_PROXY and keeps --proxy-auth', async t => {
|
|
68
|
+
const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
|
|
69
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-relay-env-'));
|
|
70
|
+
const log = join(directory, 'children.log');
|
|
71
|
+
const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: () => {} });
|
|
72
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
73
|
+
proxy.start();
|
|
74
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
75
|
+
const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
|
|
76
|
+
assert.equal(event.argv.includes('--proxy-auth'), true);
|
|
77
|
+
const noProxy = String(event.env.noProxy || '').split(',').map(value => value.trim().toLowerCase());
|
|
78
|
+
assert.ok(noProxy.includes('127.0.0.1'), `NO_PROXY must exempt the relay loopback: ${event.env.noProxy}`);
|
|
79
|
+
assert.ok(noProxy.includes('localhost'), `NO_PROXY must exempt relay localhost: ${event.env.noProxy}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('restarts a crashed VSP child and retries the tool call once', async t => {
|
|
83
|
+
const { directory, log, proxy } = await fixtureProxy();
|
|
84
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
85
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
86
|
+
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
87
|
+
const entry = proxy.children[0];
|
|
88
|
+
const originalChild = entry.child;
|
|
89
|
+
// Kill the child behind the proxy's back to simulate a crash.
|
|
90
|
+
const exit = once(originalChild.process, 'exit');
|
|
91
|
+
originalChild.process.kill('SIGKILL');
|
|
92
|
+
await exit;
|
|
93
|
+
const response = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'alpha__GetSystemInfo', arguments: {} } });
|
|
94
|
+
assert.equal(response?.error, undefined, `self-healing must recover the call: ${JSON.stringify(response?.error)}`);
|
|
95
|
+
assert.notEqual(proxy.children[0].child, originalChild, 'a replacement child must have been spawned');
|
|
96
|
+
const events = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
97
|
+
assert.ok(events.some(row => row.event === 'initialize' && row.destination === 'alpha'), 'replacement child must re-initialize');
|
|
98
|
+
assert.ok(events.some(row => row.event === 'call' && row.name === 'GetSystemInfo'), 'the retried call must reach the child');
|
|
99
|
+
});
|
|
100
|
+
|
|
50
101
|
test('closes each Cloud Foundry route after child exit and on startup failure', async t => {
|
|
51
102
|
const directory = await mkdtemp(join(tmpdir(), 'bas-cf-child-lifecycle-'));
|
|
52
103
|
const log = join(directory, 'children.log');
|
|
@@ -89,7 +140,8 @@ test('closes each Cloud Foundry route after child exit and on startup failure',
|
|
|
89
140
|
spawn: () => { throw new Error('spawn denied'); },
|
|
90
141
|
log: () => {}
|
|
91
142
|
});
|
|
92
|
-
|
|
143
|
+
failedProxy.start();
|
|
144
|
+
await assert.rejects(() => failedProxy.starting, /No destination child could be started/);
|
|
93
145
|
await new Promise(resolve => setImmediate(resolve));
|
|
94
146
|
assert.equal(failedStartCloseCount, 1);
|
|
95
147
|
});
|
|
@@ -302,9 +354,13 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
|
|
|
302
354
|
const child = proxy.children.find(entry => entry.destination.name === 'alpha').child;
|
|
303
355
|
child.process.kill('SIGKILL');
|
|
304
356
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
305
|
-
|
|
306
|
-
|
|
357
|
+
// A crashed child no longer fails the call: self-healing restarts it and
|
|
358
|
+
// retries once, so the request goes through to a fresh child.
|
|
359
|
+
const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
|
|
360
|
+
assert.equal(healed?.error, undefined, `call must heal after child crash: ${JSON.stringify(healed?.error)}`);
|
|
361
|
+
assert.equal(healed.result.content[0].text, 'alpha:GetSource');
|
|
307
362
|
assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
|
|
363
|
+
assert.ok(logs.some(message => message.includes('self-healing restart complete')), 'restart must be logged');
|
|
308
364
|
});
|
|
309
365
|
test('stdin EOF shuts down every child process and forwards child logs', async t => {
|
|
310
366
|
const { directory, log, proxy } = await fixtureProxy();
|