@inneranimalmedia/agentsam-sdk 2.6.0 → 2.6.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/docs/PLATFORM_RUNTIME_EVENTS.md +48 -0
- package/docs/RELEASES.md +7 -7
- package/docs/SOURCE_ARCHITECTURE.md +58 -0
- package/docs/TEST_TIERS.md +26 -0
- package/migrations/runtime/0001_cli_runtime.sql +298 -0
- package/package.json +28 -7
- package/packages/agentsam-repository/README.md +15 -0
- package/packages/agentsam-repository/package.json +25 -0
- package/packages/agentsam-repository/src/contracts.js +113 -0
- package/packages/agentsam-repository/src/index.js +3 -0
- package/{src/lib → packages/agentsam-repository/src}/merkle/cloudflare-persistence.js +14 -24
- package/{src/lib → packages/agentsam-repository/src}/merkle/index.js +1 -0
- package/{src/lib → packages/agentsam-repository/src}/merkle/persistence.js +6 -4
- package/{src/lib → packages/agentsam-repository/src}/merkle/policy.js +1 -0
- package/packages/agentsam-repository/test/contracts.test.mjs +40 -0
- package/packages/agentsam-repository/test/git-context.test.mjs +24 -0
- package/{test/merkle.test.mjs → packages/agentsam-repository/test/merkle-core.test.mjs} +2 -32
- package/{test → packages/agentsam-repository/test}/merkle-persistence.test.mjs +11 -6
- package/packages/identity/package.json +1 -1
- package/protocol/COMPANY_REPOSITORY_GRAPH_V1.md +91 -0
- package/protocol/MERKLE_PERSISTENCE_V1.md +2 -0
- package/protocol/MERKLE_PERSISTENCE_V2.md +40 -0
- package/protocol/repository/repository-contract.schema.json +24 -0
- package/protocol/repository/repository-dependency.schema.json +24 -0
- package/protocol/repository/repository-identity.schema.json +17 -0
- package/protocol/rpc/v1/common.proto +16 -0
- package/protocol/rpc/v1/errors.proto +35 -0
- package/protocol/rpc/v1/knowledge.proto +77 -0
- package/services/knowledge/package-lock.json +333 -0
- package/services/knowledge/package.json +5 -1
- package/src/agent/responses-runner.js +63 -35
- package/src/capabilities/repository-snapshot.js +3 -3
- package/src/cli.js +23 -6
- package/src/commands/context-economics.js +17 -2
- package/src/commands/context.js +1 -1
- package/src/commands/db.js +20 -3
- package/src/commands/env.js +90 -0
- package/src/commands/knowledge.js +12 -4
- package/src/commands/merkle-persist.js +30 -11
- package/src/commands/merkle.js +1 -1
- package/src/commands/models.js +123 -65
- package/src/commands/ollama.js +26 -0
- package/src/commands/preferences.js +53 -26
- package/src/commands/shell.js +236 -48
- package/src/errors/contract.js +236 -0
- package/src/errors/index.js +14 -0
- package/src/index.js +13 -1
- package/src/knowledge/service/auth.js +13 -0
- package/src/knowledge/service/grpc-client.js +115 -0
- package/src/knowledge/service/grpc-codec.js +237 -0
- package/src/knowledge/service/grpc-server.js +83 -0
- package/src/knowledge/service/job-engine.js +248 -0
- package/src/knowledge/service/server.js +87 -135
- package/src/knowledge/source.js +1 -1
- package/src/lib/cli-preferences.js +31 -4
- package/src/lib/deploy-receipt/index.js +2 -2
- package/src/lib/knowledge-docker.js +6 -3
- package/src/lib/local-sessions.js +23 -2
- package/src/lib/local-status.js +1 -1
- package/src/lib/project-config.js +1 -1
- package/src/lib/provider-credentials.js +105 -5
- package/src/lib/slash-commands.js +4 -3
- package/src/local/migrations.js +93 -0
- package/src/local/runtime-store.js +141 -0
- package/src/local/sqlite.js +2 -0
- package/src/local-pty/server.js +113 -51
- package/src/models/discovery.js +292 -0
- package/src/providers/anthropic-messages.js +192 -0
- package/src/providers/cloudflare-chat.js +183 -0
- package/src/providers/factory.js +69 -0
- package/src/providers/gemini-generate-content.js +208 -0
- package/src/providers/index.js +5 -0
- package/src/providers/ollama-chat.js +148 -0
- package/src/providers/openai-responses.js +226 -75
- package/src/repository/index.js +14 -2
- package/src/rpc/generated/common_grpc_pb.js +1 -0
- package/src/rpc/generated/common_pb.js +536 -0
- package/src/rpc/generated/errors_grpc_pb.js +1 -0
- package/src/rpc/generated/errors_pb.js +482 -0
- package/src/rpc/generated/knowledge_grpc_pb.js +135 -0
- package/src/rpc/generated/knowledge_pb.js +2168 -0
- package/src/rpc/generated/package.json +3 -0
- package/src/security/trust-boundary.js +2 -2
- package/src/telemetry/events.js +4 -1
- package/src/ui/cli/activity.js +76 -0
- package/src/ui/cli/compaction.js +15 -0
- package/src/ui/cli/footer.js +39 -0
- package/src/ui/cli/help.js +192 -0
- package/src/ui/cli/plan.js +20 -0
- package/src/ui/cli/runtime-events.js +110 -0
- package/src/ui/cli/waiting.js +16 -0
- package/src/ui/merkle/render.js +1 -1
- package/test/cli/preferences-runtime.test.mjs +11 -0
- package/test/cli/runtime-ui.test.mjs +74 -0
- package/test/error-diagnostics.test.mjs +57 -1
- package/test/fixtures/knowledge-rpc-worker.mjs +16 -0
- package/test/integration/cli-help.test.mjs +37 -0
- package/test/integration/knowledge-rpc.test.mjs +112 -0
- package/test/integration/merkle-cli.test.mjs +61 -0
- package/test/integration/merkle-persistence-identity.test.mjs +48 -0
- package/test/integration/provider-env-cli.test.mjs +49 -0
- package/test/integration/provider-factory.test.mjs +197 -0
- package/test/integration/repository-company-graph.test.mjs +90 -0
- package/test/integration/runtime-migrations.test.mjs +82 -0
- package/test/knowledge-service.test.mjs +5 -0
- package/test/knowledge.test.mjs +16 -0
- package/test/live/terminal-transport.live.test.mjs +24 -0
- package/test/local-sessions.test.mjs +7 -1
- package/test/models.test.mjs +101 -4
- package/test/ollama.test.mjs +21 -0
- package/test/portable-context.test.mjs +1 -1
- package/test/provider-credentials.test.mjs +45 -1
- package/test/release-hygiene.test.mjs +13 -5
- package/test/responses-runner.test.mjs +3 -1
- package/test/shell.test.mjs +50 -8
- package/test/terminal/local-pty.mock.test.mjs +151 -0
- /package/{src/lib → packages/agentsam-repository/src}/git-context.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/diff.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/filemeta.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/git-ignore.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/hash.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/semantic.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/snapshot.js +0 -0
- /package/{src/lib → packages/agentsam-repository/src}/merkle/tree.js +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
process.once('message', payload => {
|
|
2
|
+
const generation = payload?.request?.generation_id || null;
|
|
3
|
+
const delay = generation === 'slow' ? 180 : 20;
|
|
4
|
+
setTimeout(() => {
|
|
5
|
+
if (generation === 'fail') {
|
|
6
|
+
process.send?.({ ok: false, error: 'fixture knowledge failure' });
|
|
7
|
+
} else {
|
|
8
|
+
process.send?.({ ok: true, result: {
|
|
9
|
+
operation: payload?.request?.operation || null,
|
|
10
|
+
repository_id: payload?.config?.repository_id || null,
|
|
11
|
+
generation_id: generation,
|
|
12
|
+
} });
|
|
13
|
+
}
|
|
14
|
+
setTimeout(() => process.exit(0), 5);
|
|
15
|
+
}, delay);
|
|
16
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
const repoRoot = path.resolve(new URL('../..', import.meta.url).pathname);
|
|
7
|
+
|
|
8
|
+
function run(...args) {
|
|
9
|
+
return spawnSync(process.execPath, ['src/cli.js', ...args], { cwd: repoRoot, encoding: 'utf8' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test('agentsam help is concise, topic-aware, and deterministic outside a TTY', () => {
|
|
13
|
+
const help = run('help');
|
|
14
|
+
assert.equal(help.status, 0, help.stderr);
|
|
15
|
+
assert.doesNotMatch(help.stderr, /Unknown command/);
|
|
16
|
+
assert.match(help.stdout, /Agent Sam/);
|
|
17
|
+
assert.match(help.stdout, /Type normally to work with Agent Sam/);
|
|
18
|
+
assert.match(help.stdout, /agentsam help <topic>/);
|
|
19
|
+
assert.match(help.stdout, /press \/ for the command picker/i);
|
|
20
|
+
assert.doesNotMatch(help.stdout, /Inspect options:/);
|
|
21
|
+
|
|
22
|
+
const topic = run('help', 'runtime');
|
|
23
|
+
assert.equal(topic.status, 0, topic.stderr);
|
|
24
|
+
assert.match(topic.stdout, /Runtime \/ terminal/);
|
|
25
|
+
assert.match(topic.stdout, /agentsam connections/);
|
|
26
|
+
assert.match(topic.stdout, /agentsam deploy/);
|
|
27
|
+
|
|
28
|
+
const all = run('help', '--all');
|
|
29
|
+
assert.equal(all.status, 0, all.stderr);
|
|
30
|
+
assert.match(all.stdout, /Build \/ inspect/);
|
|
31
|
+
assert.match(all.stdout, /Create \/ extend/);
|
|
32
|
+
|
|
33
|
+
const flag = run('--help');
|
|
34
|
+
assert.equal(flag.status, 0, flag.stderr);
|
|
35
|
+
assert.match(flag.stdout, /agentsam help <topic>/);
|
|
36
|
+
assert.doesNotMatch(flag.stdout, /Inspect options:/);
|
|
37
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { defaultConfig } from '../../src/knowledge/config.js';
|
|
8
|
+
import { createKnowledgeJobEngine } from '../../src/knowledge/service/job-engine.js';
|
|
9
|
+
import { startKnowledgeHttpServer } from '../../src/knowledge/service/server.js';
|
|
10
|
+
import { startKnowledgeGrpcServer } from '../../src/knowledge/service/grpc-server.js';
|
|
11
|
+
import { createKnowledgeServiceClient } from '../../src/knowledge/service/client.js';
|
|
12
|
+
import { createKnowledgeGrpcClient } from '../../src/knowledge/service/grpc-client.js';
|
|
13
|
+
import { grpc } from '../../src/knowledge/service/grpc-codec.js';
|
|
14
|
+
|
|
15
|
+
const fixtureWorker = fileURLToPath(new URL('../fixtures/knowledge-rpc-worker.mjs', import.meta.url));
|
|
16
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
async function waitFor(client, id, wanted = new Set(['completed', 'failed'])) {
|
|
19
|
+
for (let i = 0; i < 200; i++) {
|
|
20
|
+
const job = await client.job(id);
|
|
21
|
+
if (wanted.has(job.status)) return job;
|
|
22
|
+
await sleep(10);
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Job ${id} did not reach ${[...wanted].join('/')}.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function collect(stream) {
|
|
28
|
+
const events = [];
|
|
29
|
+
for await (const event of stream) events.push(event);
|
|
30
|
+
return events;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('knowledge HTTP and gRPC adapters share auth, validation, idempotency, lifecycle, cancellation, and durable jobs', async t => {
|
|
34
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-rpc-'));
|
|
35
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
36
|
+
const root = path.join(tmp, 'repo');
|
|
37
|
+
const stateDir = path.join(tmp, 'state');
|
|
38
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
39
|
+
fs.writeFileSync(path.join(root, 'src', 'sample.js'), 'export const sample = true;\n');
|
|
40
|
+
const config = defaultConfig({ include: ['src'] });
|
|
41
|
+
const repositories = { sample: { root, config } };
|
|
42
|
+
const token = 'r'.repeat(64);
|
|
43
|
+
const engine = createKnowledgeJobEngine({ stateDir, repositories, workerPath: fixtureWorker, jobTimeoutMs: 2_000 });
|
|
44
|
+
const http = await startKnowledgeHttpServer({ engine, token, port: 0 });
|
|
45
|
+
const rpcServer = await startKnowledgeGrpcServer({ engine, token, port: 0 });
|
|
46
|
+
const httpClient = createKnowledgeServiceClient({ baseUrl: `http://127.0.0.1:${http.address.port}`, token });
|
|
47
|
+
const rpc = createKnowledgeGrpcClient({ target: rpcServer.target, token });
|
|
48
|
+
const unauthorized = createKnowledgeGrpcClient({ target: rpcServer.target, token: 'x'.repeat(64) });
|
|
49
|
+
t.after(async () => {
|
|
50
|
+
unauthorized.close();
|
|
51
|
+
rpc.close();
|
|
52
|
+
await rpcServer.close();
|
|
53
|
+
await http.close();
|
|
54
|
+
await engine.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const repos = await rpc.repositories();
|
|
58
|
+
assert.deepEqual(repos.repositories, ['sample']);
|
|
59
|
+
assert.equal(repos.repository_details[0].repository_id, config.repository_id);
|
|
60
|
+
assert.equal(repos.embeddings_enabled, false);
|
|
61
|
+
await assert.rejects(unauthorized.repositories(), error => error.status === 401 && error.grpcCode === grpc.status.UNAUTHENTICATED);
|
|
62
|
+
await assert.rejects(
|
|
63
|
+
rpc.submit({ repository: 'sample', operation: 'bogus' }),
|
|
64
|
+
error => error.status === 400 && error.grpcCode === grpc.status.INVALID_ARGUMENT,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const request = { repository: 'sample', operation: 'index', generation_id: 'equivalent' };
|
|
68
|
+
const fromHttp = await httpClient.submit(request, 'cross-transport');
|
|
69
|
+
const fromGrpc = await rpc.submit(request, 'cross-transport');
|
|
70
|
+
assert.equal(fromGrpc.id, fromHttp.id);
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
rpc.submit({ ...request, scope: 'different' }, 'cross-transport'),
|
|
73
|
+
error => error.status === 409 && error.grpcCode === grpc.status.ABORTED,
|
|
74
|
+
);
|
|
75
|
+
const completed = await waitFor(rpc, fromGrpc.id);
|
|
76
|
+
assert.equal(completed.status, 'completed');
|
|
77
|
+
assert.equal((await httpClient.job(fromGrpc.id)).result.repository_id, completed.result.repository_id);
|
|
78
|
+
|
|
79
|
+
const blocker = await rpc.submit({ repository: 'sample', operation: 'index', generation_id: 'slow' }, 'blocker');
|
|
80
|
+
await waitFor(rpc, blocker.id, new Set(['running']));
|
|
81
|
+
const watched = await rpc.submit({ repository: 'sample', operation: 'index', generation_id: 'watch' }, 'watched');
|
|
82
|
+
const lifecycle = await collect(rpc.watch(watched.id));
|
|
83
|
+
assert.deepEqual(lifecycle.map(event => event.job.status), ['queued', 'running', 'completed']);
|
|
84
|
+
assert.deepEqual(lifecycle.map(event => event.sequence), [1, 2, 3]);
|
|
85
|
+
|
|
86
|
+
const failed = await rpc.submit({ repository: 'sample', operation: 'index', generation_id: 'fail' }, 'failed');
|
|
87
|
+
const failureEvents = await collect(rpc.watch(failed.id));
|
|
88
|
+
assert.equal(failureEvents.at(-1).job.status, 'failed');
|
|
89
|
+
assert.match(failureEvents.at(-1).job.error, /fixture knowledge failure/);
|
|
90
|
+
|
|
91
|
+
const cancellable = await rpc.submit({ repository: 'sample', operation: 'index', generation_id: 'slow' }, 'cancel-watch');
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const iterator = rpc.watch(cancellable.id, { signal: controller.signal })[Symbol.asyncIterator]();
|
|
94
|
+
const first = await iterator.next();
|
|
95
|
+
assert.equal(first.done, false);
|
|
96
|
+
controller.abort();
|
|
97
|
+
const afterAbort = await iterator.next();
|
|
98
|
+
assert.equal(afterAbort.done, true);
|
|
99
|
+
assert.equal((await waitFor(rpc, cancellable.id)).status, 'completed');
|
|
100
|
+
|
|
101
|
+
const deadlineJob = await rpc.submit({ repository: 'sample', operation: 'index', generation_id: 'slow' }, 'deadline-watch');
|
|
102
|
+
let deadlineError = null;
|
|
103
|
+
try {
|
|
104
|
+
await collect(rpc.watch(deadlineJob.id, { timeoutMs: 15 }));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
deadlineError = error;
|
|
107
|
+
}
|
|
108
|
+
assert.ok(deadlineError);
|
|
109
|
+
assert.equal(deadlineError.grpcCode, grpc.status.DEADLINE_EXCEEDED);
|
|
110
|
+
assert.equal(deadlineError.status, 504);
|
|
111
|
+
assert.equal((await waitFor(rpc, deadlineJob.id)).status, 'completed');
|
|
112
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const cli = fileURLToPath(new URL('../../src/cli.js', import.meta.url));
|
|
10
|
+
|
|
11
|
+
async function fixture(t) {
|
|
12
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'agentsam-merkle-cli-test-'));
|
|
13
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
14
|
+
return root;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function write(root, name, content = name) {
|
|
18
|
+
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
|
|
19
|
+
await fs.writeFile(path.join(root, name), content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function run(args, cwd) {
|
|
23
|
+
return spawnSync(process.execPath, [cli, 'merkle', ...args], {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
timeout: 15000,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('CLI snapshot/verify/diff supports JSON, moved roots, and distinct mismatch/error exit codes', async (t) => {
|
|
31
|
+
const root = await fixture(t), moved = await fixture(t);
|
|
32
|
+
await write(root, 'a.txt', 'hello');
|
|
33
|
+
await write(moved, 'a.txt', 'hello');
|
|
34
|
+
|
|
35
|
+
const saved = run(['snapshot', '.', '--json'], root);
|
|
36
|
+
assert.equal(saved.status, 0, saved.stderr);
|
|
37
|
+
const manifest = JSON.parse(saved.stdout);
|
|
38
|
+
assert.ok(manifest.output.endsWith('merkle.json'));
|
|
39
|
+
|
|
40
|
+
const matching = run(['verify', manifest.output, '--root', moved, '--json'], root);
|
|
41
|
+
assert.equal(matching.status, 0, matching.stderr);
|
|
42
|
+
assert.equal(JSON.parse(matching.stdout).equal, true);
|
|
43
|
+
|
|
44
|
+
await write(moved, 'a.txt', 'changed');
|
|
45
|
+
await write(moved, 'b.txt', 'new');
|
|
46
|
+
const changed = run(['verify', manifest.output, '--root', moved, '--json'], root);
|
|
47
|
+
assert.equal(changed.status, 1, changed.stderr);
|
|
48
|
+
assert.deepEqual(JSON.parse(changed.stdout).stats, { unchanged: 0, modified: 1, added: 1, removed: 0 });
|
|
49
|
+
|
|
50
|
+
const diff = run(['diff', root, moved, '--json'], root);
|
|
51
|
+
assert.equal(diff.status, 1, diff.stderr);
|
|
52
|
+
|
|
53
|
+
const invalid = run(['root', '.', '--typo', '--json'], root);
|
|
54
|
+
assert.equal(invalid.status, 2);
|
|
55
|
+
assert.equal(invalid.stdout, '');
|
|
56
|
+
assert.match(JSON.parse(invalid.stderr).error, /Unknown option/);
|
|
57
|
+
|
|
58
|
+
const piped = run(['inspect', '.', '--tui'], root);
|
|
59
|
+
assert.equal(piped.status, 0, piped.stderr);
|
|
60
|
+
assert.ok(!piped.stdout.includes('\x1b'));
|
|
61
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { resolveMerklePersistenceIdentity } from '../../src/commands/merkle-persist.js';
|
|
8
|
+
|
|
9
|
+
async function gitFixture(t, remote = null) {
|
|
10
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'agentsam-merkle-identity-'));
|
|
11
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
12
|
+
execFileSync('git', ['init', '-q', root]);
|
|
13
|
+
if (remote) execFileSync('git', ['-C', root, 'remote', 'add', 'origin', remote]);
|
|
14
|
+
return root;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('Merkle persistence identity uses authenticated session + Git provider identity', async (t) => {
|
|
18
|
+
const root = await gitFixture(t, 'git@github.com:SamPrimeaux/agentsam-sdk.git');
|
|
19
|
+
const identity = resolveMerklePersistenceIdentity(root, {
|
|
20
|
+
session: { account_id: 'au_test' },
|
|
21
|
+
projectConfig: { repository: { id: 'local:should-not-win' } },
|
|
22
|
+
});
|
|
23
|
+
assert.deepEqual(identity, {
|
|
24
|
+
accountId: 'au_test',
|
|
25
|
+
repositoryId: 'github:samprimeaux/agentsam-sdk',
|
|
26
|
+
repositoryIdentitySource: 'git',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('local repositories fall back to committed project identity', async (t) => {
|
|
31
|
+
const root = await gitFixture(t);
|
|
32
|
+
const identity = resolveMerklePersistenceIdentity(root, {
|
|
33
|
+
session: { account_id: 'au_test' },
|
|
34
|
+
projectConfig: { repository: { id: 'local:fixture-repository' } },
|
|
35
|
+
});
|
|
36
|
+
assert.equal(identity.repositoryId, 'local:fixture-repository');
|
|
37
|
+
assert.equal(identity.repositoryIdentitySource, 'project_manifest');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('Merkle persistence refuses unauthenticated ownership', async (t) => {
|
|
41
|
+
const root = await gitFixture(t, 'https://github.com/SamPrimeaux/agentsam-sdk.git');
|
|
42
|
+
assert.throws(() => resolveMerklePersistenceIdentity(root, { session: null }), /agentsam_login_required_for_merkle_persistence/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Merkle persistence CLI has no account/repository ownership env shortcuts', async () => {
|
|
46
|
+
const source = await fs.readFile(new URL('../../src/commands/merkle-persist.js', import.meta.url), 'utf8');
|
|
47
|
+
assert.doesNotMatch(source, /AGENTSAM_ACCOUNT_ID|AGENTSAM_REPOSITORY_ID|--account-id|--repository-id/);
|
|
48
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(new URL('../..', import.meta.url).pathname);
|
|
9
|
+
|
|
10
|
+
test('agentsam env init creates provider profile and reusable source loader', { skip: process.platform === 'win32' }, t => {
|
|
11
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-env-cli-'));
|
|
12
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
13
|
+
const env = { ...process.env, HOME: home };
|
|
14
|
+
const result = spawnSync(process.execPath, ['src/cli.js', 'env', 'init', 'openai'], { cwd: repoRoot, env, encoding: 'utf8' });
|
|
15
|
+
assert.equal(result.status, 0, result.stderr);
|
|
16
|
+
assert.match(result.stdout, /source ~\/.agentsam\/load-agent-env\.sh openai/);
|
|
17
|
+
assert.equal(fs.statSync(path.join(home, '.agentsam', 'env.d', 'openai.env')).mode & 0o777, 0o600);
|
|
18
|
+
assert.equal(fs.statSync(path.join(home, '.agentsam', 'load-agent-env.sh')).mode & 0o777, 0o700);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
test('Cloudflare env init backfills exactly one Wrangler account without printing credentials', async t => {
|
|
23
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-env-cf-'));
|
|
24
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
25
|
+
const { runEnv } = await import('../../src/commands/env.js');
|
|
26
|
+
let output = '';
|
|
27
|
+
await runEnv(['init', 'cloudflare'], {
|
|
28
|
+
home, env: { HOME: home }, write: text => { output += text; },
|
|
29
|
+
spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify({ accounts: [{ id: '11111111111111111111111111111111', name: 'Example' }] }), stderr: '' }),
|
|
30
|
+
});
|
|
31
|
+
const profile = fs.readFileSync(path.join(home, '.agentsam', 'env.d', 'cloudflare.env'), 'utf8');
|
|
32
|
+
assert.match(profile, /ACCOUNT_ID="11111111111111111111111111111111"/);
|
|
33
|
+
assert.match(profile, /CLOUDFLARE_API_TOKEN=""/);
|
|
34
|
+
assert.match(output, /account detected\/configured/);
|
|
35
|
+
assert.doesNotMatch(output, /CLOUDFLARE_API_TOKEN=.*[^ ]/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
test('generated loader exports the selected provider profile into the caller shell', { skip: process.platform === 'win32' }, t => {
|
|
40
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-env-source-'));
|
|
41
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
42
|
+
const env = { ...process.env, HOME: home };
|
|
43
|
+
let result = spawnSync(process.execPath, ['src/cli.js', 'env', 'init', 'openai'], { cwd: repoRoot, env, encoding: 'utf8' });
|
|
44
|
+
assert.equal(result.status, 0, result.stderr);
|
|
45
|
+
fs.writeFileSync(path.join(home, '.agentsam', 'env.d', 'openai.env'), 'export OPENAI_API_KEY="sentinel-provider-key"\
|
|
46
|
+
', { mode: 0o600 });
|
|
47
|
+
result = spawnSync('bash', ['-lc', 'source "$HOME/.agentsam/load-agent-env.sh" openai && test "$OPENAI_API_KEY" = "sentinel-provider-key"'], { env, encoding: 'utf8' });
|
|
48
|
+
assert.equal(result.status, 0, result.stderr);
|
|
49
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createProviderAdapter } from '../../src/providers/index.js';
|
|
4
|
+
|
|
5
|
+
function response(body, status = 200, headers = {}) {
|
|
6
|
+
return {
|
|
7
|
+
ok: status >= 200 && status < 300,
|
|
8
|
+
status,
|
|
9
|
+
headers: { get(name) { return headers[String(name).toLowerCase()] || null; } },
|
|
10
|
+
async json() { return body; },
|
|
11
|
+
async text() { return JSON.stringify(body); },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('OpenAI-compatible adapter accepts provider-discovered records and preserves cache key', async () => {
|
|
16
|
+
let body;
|
|
17
|
+
const record = {
|
|
18
|
+
model_key: 'openai:account-model',
|
|
19
|
+
provider: 'openai',
|
|
20
|
+
provider_model_id: 'account-model',
|
|
21
|
+
context_window: 128000,
|
|
22
|
+
max_output_tokens: 8192,
|
|
23
|
+
reasoning_efforts: ['auto'],
|
|
24
|
+
service_tiers: ['default'],
|
|
25
|
+
capabilities: { responses: true },
|
|
26
|
+
pricing: null,
|
|
27
|
+
};
|
|
28
|
+
const provider = createProviderAdapter({
|
|
29
|
+
modelRecord: record,
|
|
30
|
+
credential: { value: 'key' },
|
|
31
|
+
fetchImpl: async (url, init) => {
|
|
32
|
+
assert.match(url, /\/v1\/responses$/);
|
|
33
|
+
body = JSON.parse(init.body);
|
|
34
|
+
return response({
|
|
35
|
+
id: 'resp_1',
|
|
36
|
+
status: 'completed',
|
|
37
|
+
output_text: 'hello',
|
|
38
|
+
output: [],
|
|
39
|
+
usage: { input_tokens: 12, output_tokens: 3, input_tokens_details: { cached_tokens: 5 } },
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const result = await provider.create({
|
|
44
|
+
model: record.provider_model_id,
|
|
45
|
+
modelRecord: record,
|
|
46
|
+
input: 'hi',
|
|
47
|
+
reasoningEffort: 'auto',
|
|
48
|
+
serviceTier: 'default',
|
|
49
|
+
promptCacheKey: 'session-1',
|
|
50
|
+
});
|
|
51
|
+
assert.equal(body.prompt_cache_key, 'session-1');
|
|
52
|
+
assert.equal(body.reasoning, undefined);
|
|
53
|
+
assert.equal(result.output_text, 'hello');
|
|
54
|
+
assert.equal(result.provider_state.previous_response_id, 'resp_1');
|
|
55
|
+
assert.equal(result.usage_snapshot.current_context.window_tokens, 128000);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
test('xAI adapter supports native compaction without requiring a live API key in tests', async () => {
|
|
61
|
+
const requests = [];
|
|
62
|
+
const record = {
|
|
63
|
+
model_key: 'grok:grok-test',
|
|
64
|
+
provider: 'grok',
|
|
65
|
+
provider_model_id: 'grok-test',
|
|
66
|
+
context_window: 256000,
|
|
67
|
+
max_output_tokens: 8192,
|
|
68
|
+
reasoning_efforts: ['auto'],
|
|
69
|
+
service_tiers: ['default'],
|
|
70
|
+
capabilities: { responses: true, compaction: true, prompt_caching: true },
|
|
71
|
+
pricing: null,
|
|
72
|
+
};
|
|
73
|
+
const provider = createProviderAdapter({
|
|
74
|
+
modelRecord: record,
|
|
75
|
+
credential: { value: 'xai-key' },
|
|
76
|
+
fetchImpl: async (url, init) => {
|
|
77
|
+
const body = JSON.parse(init.body);
|
|
78
|
+
requests.push({ url, body });
|
|
79
|
+
if (url.endsWith('/responses/compact')) {
|
|
80
|
+
return response({
|
|
81
|
+
id: 'cmp_1',
|
|
82
|
+
object: 'response.compaction',
|
|
83
|
+
output: [{ type: 'compaction', id: 'cmp_1', encrypted_content: 'opaque' }],
|
|
84
|
+
usage: { input_tokens: 1000, output_tokens: 120, input_tokens_details: { cached_tokens: 0 } },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return response({
|
|
88
|
+
id: 'resp_xai_1',
|
|
89
|
+
status: 'completed',
|
|
90
|
+
output_text: 'first answer',
|
|
91
|
+
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'first answer' }] }],
|
|
92
|
+
usage: { input_tokens: 50, output_tokens: 10, input_tokens_details: { cached_tokens: 20 } },
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const first = await provider.create({
|
|
98
|
+
modelRecord: record,
|
|
99
|
+
input: 'hello',
|
|
100
|
+
instructions: 'stable system prefix',
|
|
101
|
+
promptCacheKey: 'session-xai',
|
|
102
|
+
});
|
|
103
|
+
assert.equal(first.provider_state.previous_response_id, 'resp_xai_1');
|
|
104
|
+
assert.equal(Array.isArray(first.provider_state.compaction_input), true);
|
|
105
|
+
assert.equal(first.provider_state.compaction_input[0].role, 'system');
|
|
106
|
+
|
|
107
|
+
const compacted = await provider.compact({
|
|
108
|
+
modelRecord: record,
|
|
109
|
+
providerState: first.provider_state,
|
|
110
|
+
tokensBefore: 1000,
|
|
111
|
+
});
|
|
112
|
+
assert.match(requests.at(-1).url, /\/v1\/responses\/compact$/);
|
|
113
|
+
assert.equal(Array.isArray(requests.at(-1).body.input), true);
|
|
114
|
+
assert.equal(requests.at(-1).body.previous_response_id, undefined);
|
|
115
|
+
assert.deepEqual(compacted.provider_state.compaction_input, [
|
|
116
|
+
{ type: 'compaction', id: 'cmp_1', encrypted_content: 'opaque' },
|
|
117
|
+
]);
|
|
118
|
+
assert.equal(compacted.provider_state.previous_response_id, null);
|
|
119
|
+
|
|
120
|
+
await provider.create({
|
|
121
|
+
modelRecord: record,
|
|
122
|
+
providerState: compacted.provider_state,
|
|
123
|
+
input: 'continue after compaction',
|
|
124
|
+
promptCacheKey: 'session-xai',
|
|
125
|
+
});
|
|
126
|
+
assert.deepEqual(requests.at(-1).body.input, [
|
|
127
|
+
{ type: 'compaction', id: 'cmp_1', encrypted_content: 'opaque' },
|
|
128
|
+
{ role: 'user', content: 'continue after compaction' },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
test('Anthropic adapter keeps append-only provider state and tool calls', async () => {
|
|
134
|
+
const record = {
|
|
135
|
+
model_key: 'anthropic:claude-test', provider: 'anthropic', provider_model_id: 'claude-test',
|
|
136
|
+
context_window: 200000, max_output_tokens: 4096, reasoning_efforts: ['auto'], service_tiers: ['default'], pricing: null,
|
|
137
|
+
};
|
|
138
|
+
const provider = createProviderAdapter({
|
|
139
|
+
modelRecord: record,
|
|
140
|
+
credential: { value: 'key' },
|
|
141
|
+
fetchImpl: async () => response({
|
|
142
|
+
id: 'msg_1', stop_reason: 'tool_use',
|
|
143
|
+
content: [{ type: 'tool_use', id: 'toolu_1', name: 'as_test', input: { a: 1 } }],
|
|
144
|
+
usage: { input_tokens: 20, output_tokens: 5, cache_read_input_tokens: 7 },
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
const result = await provider.create({
|
|
148
|
+
modelRecord: record,
|
|
149
|
+
input: 'do it',
|
|
150
|
+
instructions: 'stable prefix',
|
|
151
|
+
tools: [{ type: 'function', name: 'as_test', description: 'test', parameters: { type: 'object', properties: {} } }],
|
|
152
|
+
});
|
|
153
|
+
assert.equal(result.tool_calls[0].call_id, 'toolu_1');
|
|
154
|
+
assert.equal(result.provider_state.messages.at(-1).role, 'assistant');
|
|
155
|
+
assert.equal(result.usage_snapshot.cumulative.cached_input_tokens, 7);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('Gemini adapter maps provider token limits and function calls into AgentSam shape', async () => {
|
|
159
|
+
const record = {
|
|
160
|
+
model_key: 'gemini:gemini-test', provider: 'gemini', provider_model_id: 'gemini-test',
|
|
161
|
+
context_window: 1000000, max_output_tokens: 8192, reasoning_efforts: ['auto'], service_tiers: ['default'], pricing: null,
|
|
162
|
+
};
|
|
163
|
+
const provider = createProviderAdapter({
|
|
164
|
+
modelRecord: record,
|
|
165
|
+
credential: { value: 'key' },
|
|
166
|
+
fetchImpl: async () => response({
|
|
167
|
+
responseId: 'gem_1',
|
|
168
|
+
candidates: [{ finishReason: 'STOP', content: { role: 'model', parts: [{ functionCall: { name: 'as_test', args: { x: 2 } } }] } }],
|
|
169
|
+
usageMetadata: { promptTokenCount: 30, candidatesTokenCount: 8, cachedContentTokenCount: 10, thoughtsTokenCount: 2 },
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
const result = await provider.create({ modelRecord: record, input: 'go', tools: [{ type: 'function', name: 'as_test', parameters: { type: 'object' } }] });
|
|
173
|
+
assert.equal(result.tool_calls[0].name, 'as_test');
|
|
174
|
+
assert.equal(result.usage_snapshot.current_context.window_tokens, 1000000);
|
|
175
|
+
assert.equal(result.usage_snapshot.cumulative.reasoning_tokens, 2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('Cloudflare adapter executes account-scoped Workers AI through the connected account', async () => {
|
|
179
|
+
let seen;
|
|
180
|
+
const record = {
|
|
181
|
+
model_key: 'cloudflare:@cf/test/model', provider: 'cloudflare', provider_model_id: '@cf/test/model',
|
|
182
|
+
context_window: null, max_output_tokens: null, reasoning_efforts: ['auto'], service_tiers: ['default'], pricing: null,
|
|
183
|
+
};
|
|
184
|
+
const provider = createProviderAdapter({
|
|
185
|
+
modelRecord: record,
|
|
186
|
+
credential: { value: 'cf-token', account_id: '1234567890abcdef1234567890abcdef' },
|
|
187
|
+
fetchImpl: async (url, init) => {
|
|
188
|
+
seen = { url, headers: init.headers, body: JSON.parse(init.body) };
|
|
189
|
+
return response({ id: 'cf_1', choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: 'done' } }], usage: { prompt_tokens: 9, completion_tokens: 2 } });
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
const result = await provider.create({ modelRecord: record, input: 'go' });
|
|
193
|
+
assert.match(seen.url, /accounts\/1234567890abcdef1234567890abcdef\/ai\/v1\/chat\/completions$/);
|
|
194
|
+
assert.equal(seen.headers['cf-aig-gateway-id'], 'default');
|
|
195
|
+
assert.equal(seen.body.model, '@cf/test/model');
|
|
196
|
+
assert.equal(result.output_text, 'done');
|
|
197
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import {
|
|
6
|
+
COMPANY_REPOSITORY_GRAPH_SCHEMA_VERSION,
|
|
7
|
+
createRepositoryContract,
|
|
8
|
+
createRepositoryDependency,
|
|
9
|
+
createRepositoryIdentity,
|
|
10
|
+
} from '../../packages/agentsam-repository/src/contracts.js';
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(import.meta.dirname, '../..');
|
|
13
|
+
|
|
14
|
+
function schema(relative) {
|
|
15
|
+
return JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('portable repository identity is account-independent', () => {
|
|
19
|
+
const identity = createRepositoryIdentity({
|
|
20
|
+
repository_id: 'repo_agentsam_sdk',
|
|
21
|
+
full_name: 'SamPrimeaux/agentsam-sdk',
|
|
22
|
+
role: 'contract/runtime SDK',
|
|
23
|
+
default_branch: 'main',
|
|
24
|
+
});
|
|
25
|
+
assert.equal(identity.schema_version, COMPANY_REPOSITORY_GRAPH_SCHEMA_VERSION);
|
|
26
|
+
assert.equal(identity.repository_id, 'repo_agentsam_sdk');
|
|
27
|
+
assert.equal(Object.hasOwn(identity, 'account_id'), false);
|
|
28
|
+
assert.throws(() => createRepositoryIdentity({
|
|
29
|
+
repository_id: 'repo_x', full_name: 'owner/repo', role: 'test', account_id: 'au_test',
|
|
30
|
+
}), /must not embed account_id/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('repository contracts use account and repository SSOT only', () => {
|
|
34
|
+
const record = createRepositoryContract({
|
|
35
|
+
id: 'rct_runtime_receipts_v2',
|
|
36
|
+
account_id: 'au_test',
|
|
37
|
+
repository_id: 'repo_agentsam_sdk',
|
|
38
|
+
contract_key: 'runtime-receipts',
|
|
39
|
+
contract_version: '2',
|
|
40
|
+
contract_type: 'schema',
|
|
41
|
+
name: 'Runtime receipts',
|
|
42
|
+
manifest_path: 'protocol/telemetry',
|
|
43
|
+
contract_hash: `sha256:${'1'.repeat(64)}`,
|
|
44
|
+
});
|
|
45
|
+
assert.equal(record.account_id, 'au_test');
|
|
46
|
+
assert.equal(record.contract_type, 'schema');
|
|
47
|
+
assert.equal(record.status, 'active');
|
|
48
|
+
assert.throws(() => createRepositoryContract({
|
|
49
|
+
...record, id: 'bad', workspace_id: 'ws_old',
|
|
50
|
+
}), /legacy ownership field/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('repository dependencies encode cross-repo criticality and failure policy', () => {
|
|
54
|
+
const edge = createRepositoryDependency({
|
|
55
|
+
id: 'rdep_iam_execos',
|
|
56
|
+
account_id: 'au_test',
|
|
57
|
+
source_repository_id: 'repo_inneranimalmedia',
|
|
58
|
+
target_repository_id: 'repo_execos',
|
|
59
|
+
dependency_type: 'runtime',
|
|
60
|
+
criticality: 'critical',
|
|
61
|
+
required_version: '1',
|
|
62
|
+
required_contract_hash: `sha256:${'2'.repeat(64)}`,
|
|
63
|
+
failure_policy: 'block_deploy',
|
|
64
|
+
});
|
|
65
|
+
assert.equal(edge.dependency_type, 'runtime');
|
|
66
|
+
assert.equal(edge.criticality, 'critical');
|
|
67
|
+
assert.equal(edge.failure_policy, 'block_deploy');
|
|
68
|
+
assert.throws(() => createRepositoryDependency({ ...edge, criticality: 'whatever' }), /unsupported criticality/);
|
|
69
|
+
assert.throws(() => createRepositoryDependency({ ...edge, owner_user_id: 'usr_old' }), /legacy ownership field/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('published JSON schemas expose the same ownership boundary', () => {
|
|
73
|
+
const identity = schema('protocol/repository/repository-identity.schema.json');
|
|
74
|
+
const contract = schema('protocol/repository/repository-contract.schema.json');
|
|
75
|
+
const dependency = schema('protocol/repository/repository-dependency.schema.json');
|
|
76
|
+
|
|
77
|
+
assert.equal(identity.additionalProperties, false);
|
|
78
|
+
assert.equal(Object.hasOwn(identity.properties, 'account_id'), false);
|
|
79
|
+
assert.ok(contract.required.includes('account_id'));
|
|
80
|
+
assert.ok(contract.required.includes('repository_id'));
|
|
81
|
+
assert.ok(dependency.required.includes('account_id'));
|
|
82
|
+
assert.ok(dependency.required.includes('source_repository_id'));
|
|
83
|
+
assert.ok(dependency.required.includes('target_repository_id'));
|
|
84
|
+
|
|
85
|
+
for (const item of [identity, contract, dependency]) {
|
|
86
|
+
for (const legacy of ['tenant_id', 'workspace_id', 'user_id', 'owner_user_id']) {
|
|
87
|
+
assert.equal(Object.hasOwn(item.properties, legacy), false, `${legacy} must not be part of ${item.title}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|