verify-opencode-cache-stability.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. import { spawn, spawnSync } from 'node:child_process';
  2. import {
  3. existsSync,
  4. mkdirSync,
  5. mkdtempSync,
  6. rmSync,
  7. symlinkSync,
  8. writeFileSync,
  9. } from 'node:fs';
  10. import { createServer } from 'node:http';
  11. import { tmpdir } from 'node:os';
  12. import path from 'node:path';
  13. import { fileURLToPath } from 'node:url';
  14. import { PHASE_REMINDER, PHASE_REMINDER_TEXT } from '../src/config/constants';
  15. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  16. const repoRoot = path.resolve(__dirname, '..');
  17. const distEntry = path.join(repoRoot, 'dist', 'index.js');
  18. const MODEL_ID = 'capture-model';
  19. const PROVIDER_ID = 'capture';
  20. const TIMEOUT_MS = process.platform === 'darwin' ? 60_000 : 30_000;
  21. type Capture = {
  22. body: Record<string, unknown>;
  23. headers: Record<string, string | string[] | undefined>;
  24. url: string;
  25. };
  26. function fail(message: string): never {
  27. throw new Error(message);
  28. }
  29. function run(command: string, args: string[], cwd = repoRoot): string {
  30. const result = spawnSync(command, args, {
  31. cwd,
  32. encoding: 'utf8',
  33. stdio: ['ignore', 'pipe', 'pipe'],
  34. });
  35. if (result.status === 0) return result.stdout.trim();
  36. const detail = [result.stdout, result.stderr].filter(Boolean).join('\n');
  37. fail(`Command failed: ${command} ${args.join(' ')}\n${detail}`);
  38. }
  39. function requireOpenCodeBinary(): string {
  40. const opencode = process.env.OPENCODE_BIN;
  41. if (!opencode) {
  42. fail(
  43. 'OPENCODE_BIN is required and must point to a pre-provisioned opencode-ai@1.18.2 binary. Example: OPENCODE_BIN=/path/to/node_modules/.bin/opencode bun run verify:cache-stability',
  44. );
  45. }
  46. if (!path.isAbsolute(opencode) || !existsSync(opencode)) {
  47. fail(`OPENCODE_BIN must be an existing absolute path, got: ${opencode}`);
  48. }
  49. const version = run(opencode, ['--version']);
  50. if (!/(^|\s)1\.18\.2(\s|$)/.test(version)) {
  51. fail(`OPENCODE_BIN must be opencode-ai@1.18.2, got: ${version}`);
  52. }
  53. return opencode;
  54. }
  55. function requireLocalPluginTree() {
  56. const nodeModules = path.join(repoRoot, 'node_modules');
  57. if (!existsSync(nodeModules)) {
  58. fail(
  59. 'Local plugin dependency tree is missing. Run `bun install` before verify:cache-stability; the harness never installs dependencies.',
  60. );
  61. }
  62. for (const required of [
  63. path.join(repoRoot, 'package.json'),
  64. path.join(repoRoot, 'dist', 'index.js'),
  65. path.join(nodeModules, '@opencode-ai', 'plugin'),
  66. path.join(nodeModules, 'zod'),
  67. ]) {
  68. if (!existsSync(required)) {
  69. fail(
  70. `Local plugin dependency tree is incomplete at ${required}. Run \`bun install\` and \`bun run build\` before verify:cache-stability.`,
  71. );
  72. }
  73. }
  74. return { nodeModules, plugin: repoRoot };
  75. }
  76. function getFreePort(): Promise<number> {
  77. return new Promise((resolve, reject) => {
  78. const server = createServer();
  79. server.once('error', reject);
  80. server.listen(0, '127.0.0.1', () => {
  81. const address = server.address();
  82. if (!address || typeof address === 'string') {
  83. server.close();
  84. reject(new Error('Failed to allocate a port'));
  85. return;
  86. }
  87. server.close((error) => (error ? reject(error) : resolve(address.port)));
  88. });
  89. });
  90. }
  91. async function waitFor(url: string, predicate: () => boolean, label: string) {
  92. const deadline = Date.now() + TIMEOUT_MS;
  93. let lastError = '';
  94. while (Date.now() < deadline) {
  95. try {
  96. const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
  97. if (response.ok && predicate()) return;
  98. lastError = `status=${response.status}`;
  99. } catch (error) {
  100. lastError = error instanceof Error ? error.message : String(error);
  101. }
  102. await new Promise((resolve) => setTimeout(resolve, 100));
  103. }
  104. fail(`Timed out waiting for ${label}: ${lastError}`);
  105. }
  106. async function stop(child: ReturnType<typeof spawn>) {
  107. if (child.exitCode !== null) return;
  108. child.kill('SIGTERM');
  109. const exited = await Promise.race([
  110. new Promise<boolean>((resolve) => child.once('exit', () => resolve(true))),
  111. new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5_000)),
  112. ]);
  113. if (!exited && child.exitCode === null) {
  114. child.kill('SIGKILL');
  115. await new Promise((resolve) => child.once('exit', resolve));
  116. }
  117. }
  118. function redact(value: unknown): string {
  119. const text = JSON.stringify(value, null, 2)
  120. .replaceAll(/Bearer\s+[^\s"']+/gi, 'Bearer [REDACTED]')
  121. .replaceAll(
  122. /"(api[_-]?key|authorization)"\s*:\s*"[^"]*"/gi,
  123. '"$1":"[REDACTED]"',
  124. );
  125. return text.length > 12_000 ? `${text.slice(0, 12_000)}\n...[capped]` : text;
  126. }
  127. function describeDifference(left: string, right: string): string {
  128. const offset = [...left].findIndex(
  129. (character, index) => character !== right[index],
  130. );
  131. const index = offset === -1 ? Math.min(left.length, right.length) : offset;
  132. return JSON.stringify({
  133. leftLength: left.length,
  134. rightLength: right.length,
  135. offset: index,
  136. left: left.slice(Math.max(0, index - 200), index + 400),
  137. right: right.slice(Math.max(0, index - 200), index + 400),
  138. });
  139. }
  140. function sse(payload: Record<string, unknown>): string {
  141. return `data: ${JSON.stringify(payload)}\n\ndata: [DONE]\n\n`;
  142. }
  143. function toolResponse(id: string, name: string, argumentsJson: string): string {
  144. return sse({
  145. id: `capture-${id}`,
  146. object: 'chat.completion.chunk',
  147. choices: [
  148. {
  149. index: 0,
  150. delta: {
  151. tool_calls: [
  152. {
  153. index: 0,
  154. id,
  155. type: 'function',
  156. function: { name, arguments: argumentsJson },
  157. },
  158. ],
  159. },
  160. finish_reason: 'tool_calls',
  161. },
  162. ],
  163. });
  164. }
  165. function textResponse(text: string): string {
  166. return sse({
  167. id: 'capture-final',
  168. object: 'chat.completion.chunk',
  169. choices: [{ index: 0, delta: { content: text }, finish_reason: 'stop' }],
  170. });
  171. }
  172. function isOrchestratorPayload(body: Record<string, unknown>): boolean {
  173. return (
  174. body.model === MODEL_ID &&
  175. Array.isArray(body.tools) &&
  176. body.tools.some(
  177. (tool) =>
  178. typeof tool === 'object' &&
  179. tool !== null &&
  180. 'function' in tool &&
  181. typeof tool.function === 'object' &&
  182. tool.function !== null &&
  183. 'name' in tool.function &&
  184. tool.function.name === 'read',
  185. )
  186. );
  187. }
  188. async function createCaptureServer(readPath: string) {
  189. const requests: Capture[] = [];
  190. const server = createServer(async (request, response) => {
  191. const chunks: Buffer[] = [];
  192. for await (const chunk of request) chunks.push(Buffer.from(chunk));
  193. const raw = Buffer.concat(chunks).toString('utf8');
  194. if (request.method !== 'POST' || request.url !== '/v1/chat/completions') {
  195. response.writeHead(404).end('unexpected local capture request');
  196. return;
  197. }
  198. let body: Record<string, unknown>;
  199. try {
  200. body = JSON.parse(raw) as Record<string, unknown>;
  201. } catch {
  202. response.writeHead(400).end('invalid JSON');
  203. return;
  204. }
  205. requests.push({ body, headers: request.headers, url: request.url });
  206. const primary = isOrchestratorPayload(body);
  207. const index = requests.filter((item) =>
  208. isOrchestratorPayload(item.body),
  209. ).length;
  210. const payload = !primary
  211. ? textResponse('title')
  212. : index === 1
  213. ? toolResponse(
  214. 'call_read',
  215. 'read',
  216. JSON.stringify({ filePath: readPath }),
  217. )
  218. : index === 2
  219. ? toolResponse(
  220. 'call_todo',
  221. 'todowrite',
  222. JSON.stringify({
  223. todos: [
  224. {
  225. content: 'cache stability checked',
  226. status: 'completed',
  227. priority: 'low',
  228. },
  229. ],
  230. }),
  231. )
  232. : textResponse(
  233. index === 3 ? 'tools completed' : 'second turn completed',
  234. );
  235. response.writeHead(200, {
  236. 'content-type': 'text/event-stream',
  237. 'cache-control': 'no-cache',
  238. connection: 'keep-alive',
  239. });
  240. response.end(payload);
  241. });
  242. const port = await new Promise<number>((resolve, reject) => {
  243. server.once('error', reject);
  244. server.listen(0, '127.0.0.1', () => {
  245. const address = server.address();
  246. if (!address || typeof address === 'string')
  247. return reject(new Error('Invalid capture address'));
  248. resolve(address.port);
  249. });
  250. });
  251. return { requests, server, url: `http://127.0.0.1:${port}/v1` };
  252. }
  253. function messages(
  254. body: Record<string, unknown>,
  255. ): Array<Record<string, unknown>> {
  256. if (!Array.isArray(body.messages))
  257. fail(`Provider payload omitted messages:\n${redact(body)}`);
  258. return body.messages.filter(
  259. (message): message is Record<string, unknown> =>
  260. typeof message === 'object' && message !== null,
  261. );
  262. }
  263. function contentText(message: Record<string, unknown>): string {
  264. if (typeof message.content === 'string') return message.content;
  265. if (!Array.isArray(message.content)) return '';
  266. return message.content
  267. .map((part) =>
  268. typeof part === 'object' &&
  269. part !== null &&
  270. 'text' in part &&
  271. typeof part.text === 'string'
  272. ? part.text
  273. : '',
  274. )
  275. .join('');
  276. }
  277. function userProjection(body: Record<string, unknown>): string[] {
  278. return messages(body)
  279. .filter((message) => message.role === 'user')
  280. .map(contentText)
  281. .filter((content) => content.length > 0);
  282. }
  283. function promptProjection(body: Record<string, unknown>) {
  284. const allMessages = messages(body);
  285. const system = allMessages.filter(
  286. (message) =>
  287. typeof message === 'object' &&
  288. message !== null &&
  289. 'role' in message &&
  290. message.role === 'system',
  291. );
  292. const input = allMessages.filter(
  293. (message) =>
  294. typeof message === 'object' &&
  295. message !== null &&
  296. 'role' in message &&
  297. message.role !== 'system',
  298. );
  299. return {
  300. system,
  301. input,
  302. tools: Array.isArray(body.tools) ? body.tools : [],
  303. options: Object.fromEntries(
  304. Object.entries(body).filter(([key]) =>
  305. [
  306. 'model',
  307. 'temperature',
  308. 'top_p',
  309. 'max_tokens',
  310. 'max_completion_tokens',
  311. 'tool_choice',
  312. 'parallel_tool_calls',
  313. 'response_format',
  314. ].includes(key),
  315. ),
  316. ),
  317. };
  318. }
  319. function assertPromptPrefix(
  320. previous: Record<string, unknown>,
  321. next: Record<string, unknown>,
  322. index: number,
  323. ) {
  324. const before = promptProjection(previous);
  325. const after = promptProjection(next);
  326. for (const field of ['system', 'tools', 'options'] as const) {
  327. const left = JSON.stringify(before[field]);
  328. const right = JSON.stringify(after[field]);
  329. if (left !== right) {
  330. fail(
  331. `Prompt ${field} projection changed between requests ${index} and ${index + 1}:\n${describeDifference(left, right)}`,
  332. );
  333. }
  334. }
  335. if (after.input.length < before.input.length) {
  336. fail(`Prompt input shrank between requests ${index} and ${index + 1}`);
  337. }
  338. for (const [partIndex, part] of before.input.entries()) {
  339. if (JSON.stringify(part) !== JSON.stringify(after.input[partIndex])) {
  340. fail(
  341. `Prompt input is not prefix-stable between requests ${index} and ${index + 1}:\n${redact({ before, after })}`,
  342. );
  343. }
  344. }
  345. }
  346. function assertStability(requests: Capture[]) {
  347. const modelRequests = requests.filter((request) =>
  348. isOrchestratorPayload(request.body),
  349. );
  350. if (modelRequests.length !== 4) {
  351. fail(
  352. `Expected exactly four primary-model requests, got ${modelRequests.length}:\n${redact(requests)}`,
  353. );
  354. }
  355. const payloads = modelRequests.map((request) => request.body);
  356. const systems = payloads.map((body) =>
  357. messages(body)
  358. .filter((message) => message.role === 'system')
  359. .map((message) => JSON.stringify(message))
  360. .join('\n'),
  361. );
  362. if (systems.some((system) => system.includes(PHASE_REMINDER))) {
  363. fail(`PHASE_REMINDER leaked into system/instructions:\n${redact(systems)}`);
  364. }
  365. for (const [index, system] of systems.entries()) {
  366. if (system !== systems[0]) {
  367. const initial = systems[0];
  368. if (initial === undefined) fail('Missing initial system projection');
  369. fail(
  370. `System/instruction projection changed at request ${index}:\n${describeDifference(initial, system)}`,
  371. );
  372. }
  373. }
  374. for (const [index, payload] of payloads.entries()) {
  375. const next = payloads[index + 1];
  376. if (next) assertPromptPrefix(payload, next, index);
  377. }
  378. const firstTurn = userProjection(payloads[0]);
  379. const secondTurn = userProjection(payloads[3]);
  380. if (!firstTurn.length || secondTurn.length < firstTurn.length) {
  381. fail(`Missing transformed user prompt projection:\n${redact(payloads)}`);
  382. }
  383. for (const [index, projection] of firstTurn.entries()) {
  384. if (secondTurn[index] !== projection) {
  385. fail(
  386. `Historical transformed prompt is not prefix-stable:\n${redact({ firstTurn, secondTurn })}`,
  387. );
  388. }
  389. }
  390. const reminderCount = (value: string) =>
  391. value.split(PHASE_REMINDER_TEXT).length - 1;
  392. const eligible = [...firstTurn, ...secondTurn];
  393. if (eligible.some((projection) => reminderCount(projection) !== 1)) {
  394. fail(
  395. `Expected exactly one reminder per eligible user message:\n${redact(eligible)}`,
  396. );
  397. }
  398. const toolNames = payloads
  399. .flatMap((body) => (Array.isArray(body.tools) ? body.tools : []))
  400. .map((tool) =>
  401. typeof tool === 'object' &&
  402. tool !== null &&
  403. 'function' in tool &&
  404. typeof tool.function === 'object' &&
  405. tool.function !== null &&
  406. 'name' in tool.function
  407. ? String(tool.function.name)
  408. : '',
  409. );
  410. if (!toolNames.includes('read') || !toolNames.includes('todowrite')) {
  411. fail(`Expected read and todowrite to be available:\n${redact(toolNames)}`);
  412. }
  413. const transcript = payloads
  414. .flatMap(messages)
  415. .map((message) => JSON.stringify(message))
  416. .join('\n');
  417. if (!transcript.includes('call_read') || !transcript.includes('call_todo')) {
  418. fail(
  419. `Expected read and todowrite execution results in transcript:\n${redact(payloads)}`,
  420. );
  421. }
  422. }
  423. async function prompt(hostUrl: string, sessionID: string, text: string) {
  424. const response = await fetch(`${hostUrl}/session/${sessionID}/message`, {
  425. method: 'POST',
  426. headers: { 'content-type': 'application/json' },
  427. body: JSON.stringify({
  428. agent: 'orchestrator',
  429. model: { providerID: PROVIDER_ID, modelID: MODEL_ID },
  430. parts: [{ type: 'text', text }],
  431. }),
  432. signal: AbortSignal.timeout(TIMEOUT_MS),
  433. });
  434. if (!response.ok)
  435. fail(`Prompt failed (${response.status}): ${await response.text()}`);
  436. }
  437. async function main() {
  438. if (!existsSync(distEntry)) {
  439. fail(
  440. 'dist/index.js is missing. Run `bun run build` before verify:cache-stability.',
  441. );
  442. }
  443. const opencode = requireOpenCodeBinary();
  444. const localPlugin = requireLocalPluginTree();
  445. const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-cache-stability-'));
  446. let host: ReturnType<typeof spawn> | undefined;
  447. let capture: Awaited<ReturnType<typeof createCaptureServer>> | undefined;
  448. try {
  449. const home = path.join(tempRoot, 'home');
  450. const config = path.join(tempRoot, 'config');
  451. const cache = path.join(tempRoot, 'cache');
  452. const data = path.join(tempRoot, 'data');
  453. const state = path.join(tempRoot, 'state');
  454. const workspace = path.join(tempRoot, 'workspace');
  455. for (const directory of [home, config, cache, data, state, workspace]) {
  456. mkdirSync(directory, { recursive: true });
  457. }
  458. const readPath = path.join(workspace, 'fixture.txt');
  459. writeFileSync(readPath, 'cache stability fixture\n');
  460. const plugins = path.join(config, 'plugins');
  461. mkdirSync(plugins, { recursive: true });
  462. const configNodeModules = path.join(config, 'node_modules');
  463. mkdirSync(configNodeModules, { recursive: true });
  464. symlinkSync(
  465. localPlugin.plugin,
  466. path.join(configNodeModules, 'oh-my-opencode-slim'),
  467. );
  468. writeFileSync(
  469. path.join(config, 'package.json'),
  470. JSON.stringify({
  471. type: 'module',
  472. dependencies: { 'oh-my-opencode-slim': `file:${localPlugin.plugin}` },
  473. }),
  474. );
  475. writeFileSync(
  476. path.join(plugins, 'load-plugin.js'),
  477. "export { default } from 'oh-my-opencode-slim';\n",
  478. );
  479. capture = await createCaptureServer(readPath);
  480. writeFileSync(
  481. path.join(workspace, 'opencode.json'),
  482. JSON.stringify(
  483. {
  484. $schema: 'https://opencode.ai/config.json',
  485. autoupdate: false,
  486. share: 'disabled',
  487. snapshot: false,
  488. model: `${PROVIDER_ID}/${MODEL_ID}`,
  489. small_model: `${PROVIDER_ID}/${MODEL_ID}`,
  490. default_agent: 'orchestrator',
  491. enabled_providers: [PROVIDER_ID],
  492. provider: {
  493. [PROVIDER_ID]: {
  494. npm: '@ai-sdk/openai-compatible',
  495. options: { apiKey: 'local-capture-key', baseURL: capture.url },
  496. models: {
  497. [MODEL_ID]: {
  498. name: MODEL_ID,
  499. tool_call: true,
  500. limit: { context: 32_000, output: 4_000 },
  501. },
  502. },
  503. },
  504. },
  505. },
  506. null,
  507. 2,
  508. ),
  509. );
  510. const port = await getFreePort();
  511. let logs = '';
  512. host = spawn(
  513. opencode,
  514. [
  515. 'serve',
  516. '--print-logs',
  517. '--log-level',
  518. 'DEBUG',
  519. '--hostname',
  520. '127.0.0.1',
  521. '--port',
  522. String(port),
  523. ],
  524. {
  525. cwd: workspace,
  526. env: {
  527. HOME: home,
  528. XDG_CONFIG_HOME: config,
  529. XDG_CACHE_HOME: cache,
  530. XDG_DATA_HOME: data,
  531. XDG_STATE_HOME: state,
  532. OPENCODE_CONFIG_DIR: config,
  533. OPENCODE_DISABLE_AUTOUPDATE: 'true',
  534. OPENCODE_DISABLE_MODELS_FETCH: 'true',
  535. OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true',
  536. BUN_AUTO_INSTALL: 'disable',
  537. BUN_INSTALL_CACHE_DIR: path.join(tempRoot, 'empty-bun-cache'),
  538. BUN_INSTALL_REGISTRY: 'http://127.0.0.1:9',
  539. npm_config_offline: 'true',
  540. NO_PROXY: '*',
  541. no_proxy: '*',
  542. PATH: process.env.PATH ?? '',
  543. },
  544. stdio: ['ignore', 'pipe', 'pipe'],
  545. },
  546. );
  547. host.stdout?.on('data', (chunk) => (logs += String(chunk)));
  548. host.stderr?.on('data', (chunk) => (logs += String(chunk)));
  549. const hostUrl = `http://127.0.0.1:${port}`;
  550. await waitFor(`${hostUrl}/global/health`, () => true, 'OpenCode health');
  551. const created = await fetch(`${hostUrl}/session`, {
  552. method: 'POST',
  553. signal: AbortSignal.timeout(TIMEOUT_MS),
  554. });
  555. if (!created.ok)
  556. fail(
  557. `Session creation failed: ${await created.text()}\n${logs.slice(-12_000)}`,
  558. );
  559. const session = (await created.json()) as { id?: string };
  560. if (!session.id) fail(`Session creation omitted id: ${redact(session)}`);
  561. try {
  562. await prompt(
  563. hostUrl,
  564. session.id,
  565. 'First cache-stability turn: use the requested tools.',
  566. );
  567. } catch (error) {
  568. fail(
  569. `${error instanceof Error ? error.message : String(error)}\nOpenCode logs:\n${logs.slice(-12_000)}`,
  570. );
  571. }
  572. try {
  573. await waitFor(
  574. `${hostUrl}/global/health`,
  575. () =>
  576. capture?.requests.filter((request) =>
  577. isOrchestratorPayload(request.body),
  578. ).length === 3,
  579. 'tool execution',
  580. );
  581. } catch (error) {
  582. fail(
  583. `${error instanceof Error ? error.message : String(error)}\nCaptured requests:\n${redact(capture?.requests)}\nOpenCode logs:\n${logs.slice(-12_000)}`,
  584. );
  585. }
  586. await prompt(
  587. hostUrl,
  588. session.id,
  589. 'Second cache-stability turn: finish without tools.',
  590. );
  591. await waitFor(
  592. `${hostUrl}/global/health`,
  593. () =>
  594. capture?.requests.filter((request) =>
  595. isOrchestratorPayload(request.body),
  596. ).length === 4,
  597. 'second turn',
  598. );
  599. assertStability(capture.requests);
  600. console.log('OpenCode cache-stability verification passed.');
  601. } finally {
  602. if (host) await stop(host);
  603. const runningCapture = capture;
  604. if (runningCapture) {
  605. await new Promise<void>((resolve) =>
  606. runningCapture.server.close(() => resolve()),
  607. );
  608. }
  609. rmSync(tempRoot, { recursive: true, force: true });
  610. }
  611. }
  612. await main();