diff --git a/package.json b/package.json
index 93430c55..46d07bfb 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs",
- "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs",
- "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
- "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
+ "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs",
+ "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
+ "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js",
diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs
index b3e8e400..1205ebe7 100644
--- a/server/orchestrator.mjs
+++ b/server/orchestrator.mjs
@@ -1,325 +1,35 @@
-// contextual-orchestrator client. Production requires an authenticated endpoint;
-// deterministic responses exist only under the explicit SCOPEWEAVE_DEV=1 boundary.
+// contextual-orchestrator(LLM 오케스트레이션) 클라이언트.
+// 실서버: ORCHESTRATOR_URL + ORCHESTRATOR_TOKEN 설정 시 OpenAI 호환
+// /v1/chat/completions 호출. 미설정 시 결정적 MOCK으로 전 플로우 테스트 가능.
const OC_URL = (process.env.ORCHESTRATOR_URL || '').replace(/\/$/, '');
const OC_TOKEN = process.env.ORCHESTRATOR_TOKEN || '';
-const OC_MODEL = process.env.ORCHESTRATOR_MODEL || 'contextual-orchestrator';
-const ORCHESTRATOR_TIMEOUT_MS = 120_000;
-const MAX_MESSAGE_COUNT = 256;
-const MAX_CONTENT_LENGTH = 100_000;
-const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024;
-// WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`).
-const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
-export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL;
+export const orchestratorMock = !OC_URL;
-/** Stable provider-boundary failure for AI briefing requests. */
-export class OrchestratorConfigurationError extends Error {
- /**
- * Create one operator-safe orchestrator error.
- * @param {string} code machine-readable failure code
- * @param {string} message operator-safe detail
- */
- constructor(code, message) {
- super(message);
- this.name = 'OrchestratorConfigurationError';
- this.code = code;
- }
-}
-
-/**
- * Resolve explicit development mode or a complete authenticated production endpoint.
- *
- * The provider setting is an origin, not an arbitrary request URL. Rejecting
- * credentials and additional URL components keeps endpoint authority separate
- * from the bearer token and prevents operator-supplied path/query/fragment data
- * from changing the fixed OpenAI-compatible request path.
- *
- * @returns {{mock: true} | {mock: false, baseUrl: string, token: string}}
- */
-function orchestratorConfiguration() {
- if (orchestratorMock) return { mock: true };
- if (!OC_URL) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_not_configured',
- 'contextual-orchestrator is unavailable because ORCHESTRATOR_URL is not configured.',
- );
- }
- let url;
- try {
- url = new URL(OC_URL);
- } catch {
- throw new OrchestratorConfigurationError(
- 'orchestrator_url_invalid',
- 'ORCHESTRATOR_URL must be a valid absolute URL.',
- );
- }
- if (!['https:', 'http:'].includes(url.protocol)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_url_invalid',
- 'ORCHESTRATOR_URL must use HTTP or HTTPS.',
- );
- }
- if (url.username || url.password) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_url_credentials_forbidden',
- 'ORCHESTRATOR_URL must not contain credentials.',
- );
- }
- if (url.pathname !== '/') {
- throw new OrchestratorConfigurationError(
- 'orchestrator_url_path_forbidden',
- 'ORCHESTRATOR_URL must identify the provider origin without a path.',
- );
- }
- if (url.search) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_url_query_forbidden',
- 'ORCHESTRATOR_URL must not contain a query string.',
- );
- }
- if (url.hash) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_url_fragment_forbidden',
- 'ORCHESTRATOR_URL must not contain a fragment.',
- );
- }
- if (url.protocol !== 'https:' && !LOOPBACK_HOSTNAMES.has(url.hostname)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_transport_insecure',
- 'contextual-orchestrator production traffic requires HTTPS.',
- );
- }
- if (!OC_TOKEN.trim()) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_token_missing',
- 'ORCHESTRATOR_TOKEN is required for production requests.',
- );
- }
- return { mock: false, baseUrl: url.origin, token: OC_TOKEN };
-}
-
-/**
- * Validate and copy OpenAI-compatible messages without accepting unbounded content.
- * @param {unknown} messages candidate conversation
- * @returns {{role: string, content: string}[]}
- */
-function validatedMessages(messages) {
- if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGE_COUNT) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_messages_invalid',
- 'Orchestrator messages must be a non-empty bounded array.',
- );
- }
- return messages.map((message) => {
- if (!message || typeof message !== 'object' || Array.isArray(message)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_message_invalid',
- 'Each orchestrator message must be an object.',
- );
- }
- if (!['system', 'developer', 'user', 'assistant'].includes(message.role)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_message_role_invalid',
- 'Orchestrator message role is unsupported.',
- );
- }
- if (
- typeof message.content !== 'string'
- || message.content.length === 0
- || message.content.length > MAX_CONTENT_LENGTH
- ) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_message_content_invalid',
- 'Orchestrator message content is outside the accepted boundary.',
- );
- }
- return { role: message.role, content: message.content };
- });
-}
-
-/**
- * Build the stable response-size failure used by declared and streamed limits.
- * @returns {OrchestratorConfigurationError} Operator-safe size error.
- */
-function responseSizeError() {
- return new OrchestratorConfigurationError(
- 'orchestrator_response_size_invalid',
- 'contextual-orchestrator response size is outside the accepted boundary.',
- );
-}
-
-/**
- * Read one provider body without ever buffering more than the configured limit.
- *
- * A trustworthy numeric Content-Length can reject an oversized response before
- * body allocation. The stream reader remains authoritative because providers
- * may omit or misstate that header. The reader is cancelled as soon as the
- * accumulated byte count exceeds the limit.
- *
- * @param {Response} response provider response
- * @returns {Promise
} Non-empty bounded response bytes.
- */
-async function boundedResponseBytes(response) {
- const declaredLength = response.headers?.get?.('content-length');
- if (declaredLength !== null && declaredLength !== undefined && declaredLength !== '') {
- const normalizedLength = String(declaredLength).trim();
- if (!/^\d+$/.test(normalizedLength)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator returned an invalid response length.',
- );
- }
- const length = Number(normalizedLength);
- if (!Number.isSafeInteger(length)) throw responseSizeError();
- if (length === 0 || length > MAX_PROVIDER_RESPONSE_BYTES) throw responseSizeError();
- }
-
- const reader = response.body?.getReader?.();
- if (!reader || typeof reader.read !== 'function') {
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator response body is not stream-readable.',
- );
- }
-
- const chunks = [];
- let totalBytes = 0;
- try {
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- if (!(value instanceof Uint8Array)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator returned an invalid response chunk.',
- );
- }
- totalBytes += value.byteLength;
- if (totalBytes > MAX_PROVIDER_RESPONSE_BYTES) {
- try {
- await reader.cancel();
- } catch {
- // Cancellation is best effort after the byte budget has already failed closed.
- }
- throw responseSizeError();
- }
- chunks.push(Buffer.from(value));
- }
- } catch (error) {
- if (error instanceof OrchestratorConfigurationError) throw error;
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator response could not be read.',
- );
- } finally {
- try {
- reader.releaseLock?.();
- } catch {
- // Releasing a consumed/cancelled reader is cleanup only and cannot alter the result.
- }
- }
-
- if (totalBytes === 0) throw responseSizeError();
- return Buffer.concat(chunks, totalBytes);
-}
-
-/**
- * Parse one bounded provider response without returning raw provider payloads in failures.
- * @param {Response} response provider response
- * @returns {Promise>}
- */
-async function responseJson(response) {
- const bytes = await boundedResponseBytes(response);
- let data;
- try {
- data = JSON.parse(bytes.toString('utf8'));
- } catch {
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator returned a non-JSON response.',
- );
- }
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator returned an invalid response object.',
- );
- }
- return data;
-}
-
-/**
- * Cancel an unread non-success provider response before returning a fixed rejection.
- *
- * Undici-backed fetch bodies must be consumed or cancelled for predictable
- * connection reuse. Cancellation failures remain private cleanup details and
- * never replace the stable provider-rejection classification.
- *
- * @param {Response} response rejected provider response
- * @returns {Promise}
- */
-async function rejectProviderResponse(response) {
- try {
- if (response?.body && typeof response.body.cancel === 'function') {
- await response.body.cancel();
- }
- } catch {
- // Provider rejection remains authoritative even if cleanup fails.
- }
- throw new OrchestratorConfigurationError(
- 'orchestrator_provider_rejected',
- `contextual-orchestrator rejected the request with HTTP ${response.status}.`,
- );
-}
-
-/**
- * Generate one AI briefing through contextual-orchestrator.
- * @param {unknown} messages OpenAI-compatible messages
- * @returns {Promise}
- */
export async function chat(messages) {
- const configuration = orchestratorConfiguration();
- const safeMessages = validatedMessages(messages);
- if (configuration.mock) {
- const user = safeMessages
- .filter((message) => message.role === 'user')
- .map((message) => message.content)
- .join('\n');
- return `[dev-orchestrator] 분석 요약: ${user.slice(0, 120)}…에 대한 개발 응답입니다. `
+ if (orchestratorMock) {
+ const user = messages.filter((m) => m.role === 'user').map((m) => m.content).join('\n');
+ return `[mock-orchestrator] 분석 요약: ${user.slice(0, 120)}…에 대한 모의 응답입니다. `
+ '리스크: 지연 작업을 우선 점검하세요. 권고: 임계경로 작업의 담당자 부하를 재배분하세요.';
}
- if (typeof globalThis.fetch !== 'function') {
- throw new OrchestratorConfigurationError(
- 'orchestrator_transport_unavailable',
- 'Orchestrator HTTP transport is unavailable.',
- );
- }
-
- let response;
+ const ctrl = new AbortController();
+ const to = setTimeout(() => ctrl.abort(), 60000);
try {
- response = await globalThis.fetch(`${configuration.baseUrl}/v1/chat/completions`, {
+ const res = await fetch(`${OC_URL}/v1/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
- authorization: `Bearer ${configuration.token}`,
+ ...(OC_TOKEN ? { authorization: `Bearer ${OC_TOKEN}` } : {}),
},
- body: JSON.stringify({ model: OC_MODEL, messages: safeMessages }),
- signal: AbortSignal.timeout(ORCHESTRATOR_TIMEOUT_MS),
+ // orchestrator는 알 수 없는 필드를 거부(strict validation) — model+messages만 전송.
+ body: JSON.stringify({ model: 'contextual-orchestrator', messages }),
+ signal: ctrl.signal,
});
- } catch {
- throw new OrchestratorConfigurationError(
- 'orchestrator_provider_unavailable',
- 'contextual-orchestrator could not be reached.',
- );
- }
- if (!response.ok) return rejectProviderResponse(response);
- const data = await responseJson(response);
- const content = data?.choices?.[0]?.message?.content;
- if (typeof content !== 'string' || !content.trim()) {
- throw new OrchestratorConfigurationError(
- 'orchestrator_response_invalid',
- 'contextual-orchestrator returned no assistant content.',
- );
+ const data = await res.json().catch(() => ({}));
+ const content = data?.choices?.[0]?.message?.content;
+ if (!res.ok || !content) throw new Error(data?.error?.message || `orchestrator failed (${res.status})`);
+ return content;
+ } finally {
+ clearTimeout(to);
}
- return content;
}
diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs
index 5ecf351a..8cb0f4a2 100644
--- a/tests/api/smoke.mjs
+++ b/tests/api/smoke.mjs
@@ -5,7 +5,6 @@ import assert from 'node:assert';
process.env.SCOPEWEAVE_DB = ':memory:';
process.env.SCOPEWEAVE_DEV = '1'; // enables the dev-activate-pro endpoint for this test
-delete process.env.ORCHESTRATOR_URL; // keep the AI briefing on the explicit local dev adapter
process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef';
const { app } = await import('../../server/app.mjs');
@@ -620,7 +619,7 @@ assert.equal(r.status, 200, 'sprint delete');
r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: auth });
assert.equal(r.status, 200, 'ai brief 200');
const brief = await r.json();
-assert.ok(brief.analysis.includes('dev-orchestrator'), 'explicit development analysis returned');
+assert.ok(brief.analysis.includes('mock-orchestrator'), 'mock analysis returned');
assert.ok(brief.analysis.length > 40, 'non-trivial analysis');
r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: oauth });
assert.equal(r.status, 404, 'non-member ai brief → 404');
@@ -748,4 +747,4 @@ assert.equal((await r.json()).orgs.find((o) => o.id === orgAId)?.role, 'admin',
r = await req(`/api/orgs/${orgAId}/leave`, { method: 'POST', headers: auth });
assert.equal(r.status, 200, 'former owner can now leave');
-console.log('✓ API smoke tests passed');
\ No newline at end of file
+console.log('✓ API smoke tests passed');
diff --git a/tests/unit/msproject.test.mjs b/tests/unit/msproject.test.mjs
index 284cb51d..d829a32c 100644
--- a/tests/unit/msproject.test.mjs
+++ b/tests/unit/msproject.test.mjs
@@ -72,53 +72,4 @@ assert.deepEqual(
const incompleteOpens = `${'9open'.repeat(5000)}`;
assert.deepEqual(parseMsProjectXml(incompleteOpens), [], 'unclosed Task blocks yield no tasks');
-assert.deepEqual(
- parseMsProjectXml(
- '11unclosed outer12nested',
- ),
- [],
- 'an unmatched outer Task cannot consume a nested Task closing tag',
-);
-
-const whitespaceTags = parseMsProjectXml(`
-
- 8
- Whitespace-compatible task
- 1
- 2026-08-11T09:00:00
- 2026-08-12T17:00:00
-
- 2
-
-`);
-assert.equal(whitespaceTags.length, 1, 'XML whitespace before tag delimiters is accepted');
-assert.equal(whitespaceTags[0].id, 'msp-8');
-assert.equal(whitespaceTags[0].phase, 'Whitespace-compatible task');
-assert.equal(whitespaceTags[0].plannedStartDate, '2026-08-11');
-assert.equal(whitespaceTags[0].plannedEndDate, '2026-08-12');
-assert.equal(whitespaceTags[0].predecessors, 'msp-2', 'block and scalar tags share the scanner');
-
-assert.deepEqual(
- parseMsProjectXml('9wrong'),
- [],
- 'TaskX must not match Task',
-);
-assert.deepEqual(
- parseMsProjectXml('9wrong whitespace'),
- [],
- 'non-XML whitespace before a delimiter is rejected',
-);
-assert.deepEqual(
- parseMsProjectXml('10truncated'),
- [],
- 'truncated whitespace-delimited Task stops safely',
-);
-assert.deepEqual(
- parseMsProjectXml(
- '13outerinner1',
- ),
- [],
- 'a nested scalar opening cannot consume the inner closing delimiter',
-);
-
console.log('✓ MS Project import tests passed');
diff --git a/tests/unit/orchestrator-coverage.test.mjs b/tests/unit/orchestrator-coverage.test.mjs
deleted file mode 100644
index d1dbd60e..00000000
--- a/tests/unit/orchestrator-coverage.test.mjs
+++ /dev/null
@@ -1,256 +0,0 @@
-import assert from 'node:assert/strict';
-
-const ORIGINAL_ENV = { ...process.env };
-const ORIGINAL_FETCH = globalThis.fetch;
-
-function restoreEnvironment() {
- for (const key of Object.keys(process.env)) {
- if (!(key in ORIGINAL_ENV)) delete process.env[key];
- }
- Object.assign(process.env, ORIGINAL_ENV);
- globalThis.fetch = ORIGINAL_FETCH;
-}
-
-function configure({ url = 'https://orchestrator.example', token = 'secret-token', dev = false } = {}) {
- process.env.ORCHESTRATOR_URL = url;
- process.env.ORCHESTRATOR_TOKEN = token;
- process.env.ORCHESTRATOR_MODEL = 'contextual-orchestrator';
- if (dev) process.env.SCOPEWEAVE_DEV = '1';
- else delete process.env.SCOPEWEAVE_DEV;
-}
-
-async function freshModule(label) {
- return import(`../../server/orchestrator.mjs?coverage=${label}-${Date.now()}-${Math.random()}`);
-}
-
-async function expectCode(module, messages, code) {
- await assert.rejects(
- module.chat(messages),
- (error) => error?.code === code,
- `expected ${code}`,
- );
-}
-
-function streamResponse({ chunks = [], headers, ok = true, status = 200, cancel, releaseLock, readError } = {}) {
- let index = 0;
- return {
- ok,
- status,
- ...(headers === undefined ? {} : { headers }),
- body: {
- getReader() {
- return {
- async read() {
- if (readError) throw readError;
- if (index >= chunks.length) return { done: true, value: undefined };
- const value = chunks[index];
- index += 1;
- return { done: false, value };
- },
- ...(cancel ? { cancel } : {}),
- ...(releaseLock ? { releaseLock } : {}),
- };
- },
- },
- };
-}
-
-try {
- configure({ url: 'not an absolute url' });
- await expectCode(
- await freshModule('invalid-url'),
- [{ role: 'user', content: 'status' }],
- 'orchestrator_url_invalid',
- );
-
- configure({ url: 'ftp://orchestrator.example' });
- await expectCode(
- await freshModule('invalid-protocol'),
- [{ role: 'user', content: 'status' }],
- 'orchestrator_url_invalid',
- );
-
- configure({ url: 'http://localhost:8080/' });
- globalThis.fetch = async (url) => {
- assert.equal(url, 'http://localhost:8080/v1/chat/completions');
- return new Response(JSON.stringify({ choices: [{ message: { content: 'loopback ok' } }] }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- };
- assert.equal(
- await (await freshModule('loopback-http')).chat([{ role: 'developer', content: 'status' }]),
- 'loopback ok',
- );
-
- configure({ url: 'http://[::1]:8080/' });
- globalThis.fetch = async (url) => {
- assert.equal(url, 'http://[::1]:8080/v1/chat/completions');
- return new Response(JSON.stringify({ choices: [{ message: { content: 'ipv6 loopback ok' } }] }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- };
- assert.equal(
- await (await freshModule('ipv6-loopback-http')).chat([{ role: 'developer', content: 'status' }]),
- 'ipv6 loopback ok',
- 'WHATWG IPv6 loopback hostname serialization must remain accepted by the documented local transport boundary',
- );
-
- configure();
- const configured = await freshModule('message-boundaries');
- globalThis.fetch = async () => new Response(JSON.stringify({
- choices: [{ message: { content: 'ok' } }],
- }), { status: 200, headers: { 'content-type': 'application/json' } });
-
- for (const invalidMessages of [
- null,
- Array.from({ length: 257 }, () => ({ role: 'user', content: 'x' })),
- [[]],
- [{ role: 'assistant', content: 42 }],
- ]) {
- await assert.rejects(
- configured.chat(invalidMessages),
- (error) => error?.code?.startsWith('orchestrator_message'),
- );
- }
- assert.equal(
- await configured.chat([
- { role: 'assistant', content: 'prior' },
- { role: 'developer', content: 'policy' },
- { role: 'user', content: 'status' },
- ]),
- 'ok',
- );
-
- const responseCases = [
- {
- label: 'invalid-content-length',
- response: streamResponse({
- headers: new Headers({ 'content-length': '12x' }),
- chunks: [new TextEncoder().encode('{}')],
- }),
- code: 'orchestrator_response_invalid',
- },
- {
- label: 'unsafe-content-length',
- response: streamResponse({
- headers: new Headers({ 'content-length': '9007199254740992' }),
- chunks: [new TextEncoder().encode('{}')],
- }),
- code: 'orchestrator_response_size_invalid',
- },
- {
- label: 'zero-content-length',
- response: streamResponse({
- headers: new Headers({ 'content-length': '0' }),
- chunks: [],
- }),
- code: 'orchestrator_response_size_invalid',
- },
- {
- label: 'missing-body',
- response: { ok: true, status: 200, headers: new Headers(), body: null },
- code: 'orchestrator_response_invalid',
- },
- {
- label: 'missing-reader',
- response: { ok: true, status: 200, headers: new Headers(), body: {} },
- code: 'orchestrator_response_invalid',
- },
- {
- label: 'invalid-chunk',
- response: streamResponse({ headers: new Headers(), chunks: ['not-bytes'] }),
- code: 'orchestrator_response_invalid',
- },
- {
- label: 'read-error',
- response: streamResponse({ headers: new Headers(), readError: new Error('private stream failure') }),
- code: 'orchestrator_response_invalid',
- },
- {
- label: 'empty-stream',
- response: streamResponse({ headers: new Headers(), chunks: [] }),
- code: 'orchestrator_response_size_invalid',
- },
- ];
-
- for (const { label, response, code } of responseCases) {
- globalThis.fetch = async () => response;
- await expectCode(configured, [{ role: 'user', content: label }], code);
- }
-
- let cancelAttempted = false;
- globalThis.fetch = async () => streamResponse({
- headers: new Headers(),
- chunks: [new Uint8Array(1024 * 1024 + 1)],
- cancel: async () => {
- cancelAttempted = true;
- throw new Error('cancel cleanup failure');
- },
- });
- await expectCode(
- configured,
- [{ role: 'user', content: 'oversized cancel failure' }],
- 'orchestrator_response_size_invalid',
- );
- assert.equal(cancelAttempted, true);
-
- let released = false;
- globalThis.fetch = async () => streamResponse({
- chunks: [new TextEncoder().encode(JSON.stringify({ choices: [{ message: { content: 'release ok' } }] }))],
- releaseLock() {
- released = true;
- throw new Error('release cleanup failure');
- },
- });
- assert.equal(
- await configured.chat([{ role: 'user', content: 'release cleanup' }]),
- 'release ok',
- );
- assert.equal(released, true);
-
- globalThis.fetch = async () => new Response('{not-json', { status: 200 });
- await expectCode(
- configured,
- [{ role: 'user', content: 'non-json response' }],
- 'orchestrator_response_invalid',
- );
-
- for (const [label, body] of [
- ['null-json', 'null'],
- ['primitive-json', '"string"'],
- ['array-json', '[]'],
- ]) {
- globalThis.fetch = async () => new Response(body, { status: 200 });
- await expectCode(configured, [{ role: 'user', content: label }], 'orchestrator_response_invalid');
- }
-
- globalThis.fetch = async () => streamResponse({
- chunks: [new TextEncoder().encode(JSON.stringify({ choices: [{ message: { content: 'no headers ok' } }] }))],
- });
- assert.equal(
- await configured.chat([{ role: 'user', content: 'missing headers object' }]),
- 'no headers ok',
- );
-
- globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 200 });
- await expectCode(
- configured,
- [{ role: 'user', content: 'missing choices' }],
- 'orchestrator_response_invalid',
- );
-
- globalThis.fetch = async () => new Response(JSON.stringify({
- choices: [{ message: { content: ' ' } }],
- }), { status: 200 });
- await expectCode(
- configured,
- [{ role: 'user', content: 'blank assistant content' }],
- 'orchestrator_response_invalid',
- );
-} finally {
- restoreEnvironment();
-}
-
-console.log('✓ orchestrator residual branch coverage tests passed');
diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs
deleted file mode 100644
index 14de7136..00000000
--- a/tests/unit/orchestrator.test.mjs
+++ /dev/null
@@ -1,262 +0,0 @@
-import assert from 'node:assert/strict';
-
-const ORIGINAL_ENV = { ...process.env };
-const ORIGINAL_FETCH = globalThis.fetch;
-
-function restoreEnvironment() {
- for (const key of Object.keys(process.env)) {
- if (!(key in ORIGINAL_ENV)) delete process.env[key];
- }
- Object.assign(process.env, ORIGINAL_ENV);
- globalThis.fetch = ORIGINAL_FETCH;
-}
-
-async function freshModule(label) {
- return import(`../../server/orchestrator.mjs?test=${label}-${Date.now()}-${Math.random()}`);
-}
-
-try {
- delete process.env.ORCHESTRATOR_URL;
- delete process.env.ORCHESTRATOR_TOKEN;
- delete process.env.ORCHESTRATOR_MODEL;
- delete process.env.SCOPEWEAVE_DEV;
- const unconfigured = await freshModule('unconfigured');
- assert.equal(unconfigured.orchestratorMock, false);
- await assert.rejects(
- unconfigured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_not_configured',
- );
-
- process.env.SCOPEWEAVE_DEV = '1';
- const development = await freshModule('development');
- assert.equal(development.orchestratorMock, true);
- const developmentResult = await development.chat([
- { role: 'system', content: 'Summarize the plan.' },
- { role: 'user', content: 'Find the critical path.' },
- ]);
- assert.match(developmentResult, /^\[dev-orchestrator\]/);
- assert.match(developmentResult, /Find the critical path/);
-
- delete process.env.SCOPEWEAVE_DEV;
- process.env.ORCHESTRATOR_URL = 'https://orchestrator.example';
- delete process.env.ORCHESTRATOR_TOKEN;
- const missingToken = await freshModule('missing-token');
- await assert.rejects(
- missingToken.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_token_missing',
- );
-
- process.env.ORCHESTRATOR_URL = 'http://orchestrator.example';
- process.env.ORCHESTRATOR_TOKEN = 'secret-token';
- const insecure = await freshModule('insecure');
- await assert.rejects(
- insecure.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_transport_insecure',
- );
-
- const invalidEndpointConfigurations = [
- ['credentials', 'https://user:pass@orchestrator.example', 'orchestrator_url_credentials_forbidden'],
- ['path', 'https://orchestrator.example/api', 'orchestrator_url_path_forbidden'],
- ['query', 'https://orchestrator.example?tenant=scopeweave', 'orchestrator_url_query_forbidden'],
- ['fragment', 'https://orchestrator.example#tenant', 'orchestrator_url_fragment_forbidden'],
- ];
- const transportBeforeEndpointChecks = globalThis.fetch;
- globalThis.fetch = async () => {
- throw new Error('invalid endpoint configuration must fail before transport');
- };
- for (const [label, url, expectedCode] of invalidEndpointConfigurations) {
- process.env.ORCHESTRATOR_URL = url;
- const invalidEndpoint = await freshModule(`invalid-endpoint-${label}`);
- await assert.rejects(
- invalidEndpoint.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === expectedCode,
- `${label} endpoint configuration fails before provider transport`,
- );
- }
- globalThis.fetch = transportBeforeEndpointChecks;
-
- process.env.ORCHESTRATOR_URL = 'https://orchestrator.example';
- process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b';
- const configured = await freshModule('configured');
- const calls = [];
- globalThis.fetch = async (url, init) => {
- calls.push({ url, init });
- return new Response(JSON.stringify({
- choices: [{ message: { content: 'Grounded production response' } }],
- }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- };
- assert.equal(
- await configured.chat([{ role: 'user', content: 'status' }]),
- 'Grounded production response',
- );
- assert.equal(calls.length, 1);
- assert.equal(calls[0].url, 'https://orchestrator.example/v1/chat/completions');
- assert.equal(calls[0].init.headers.authorization, 'Bearer secret-token');
- assert.ok(calls[0].init.signal instanceof AbortSignal);
- assert.deepEqual(JSON.parse(calls[0].init.body), {
- model: 'nvidia/nemotron-3-super-120b-a12b',
- messages: [{ role: 'user', content: 'status' }],
- });
-
- for (const invalidMessages of [
- [],
- [null],
- [{ role: 'tool', content: 'status' }],
- [{ role: 'user', content: '' }],
- [{ role: 'user', content: 'x'.repeat(100_001) }],
- ]) {
- await assert.rejects(
- configured.chat(invalidMessages),
- (error) => error.code.startsWith('orchestrator_message'),
- );
- }
-
- globalThis.fetch = async () => { throw new Error('offline'); };
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_provider_unavailable',
- );
-
- let rejectedBodyRead = false;
- let rejectedBodyCancelled = false;
- globalThis.fetch = async () => ({
- ok: false,
- status: 502,
- headers: new Headers({ 'content-type': 'text/plain' }),
- body: {
- getReader() {
- rejectedBodyRead = true;
- throw new Error('rejected provider body must not be parsed');
- },
- async cancel() {
- rejectedBodyCancelled = true;
- },
- },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_provider_rejected',
- );
- assert.equal(rejectedBodyRead, false, 'non-success provider responses are classified before body parsing');
- assert.equal(rejectedBodyCancelled, true, 'non-success provider response bodies are explicitly cancelled');
-
- globalThis.fetch = async () => ({
- ok: false,
- status: 429,
- headers: new Headers(),
- body: {
- async cancel() {
- throw new Error('private cancel failure');
- },
- },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => {
- assert.equal(error.code, 'orchestrator_provider_rejected');
- assert.doesNotMatch(error.message, /private cancel failure/);
- return true;
- },
- );
-
- globalThis.fetch = async () => ({
- ok: false,
- status: 503,
- headers: new Headers(),
- body: null,
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_provider_rejected',
- );
-
- globalThis.fetch = async () => new Response(JSON.stringify({
- choices: [{ message: { content: 'x'.repeat(1024 * 1024) } }],
- }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_response_size_invalid',
- );
-
- let knownLengthBodyRead = false;
- globalThis.fetch = async () => ({
- ok: true,
- status: 200,
- headers: new Headers({ 'content-length': String(1024 * 1024 + 1) }),
- body: {
- getReader() {
- knownLengthBodyRead = true;
- throw new Error('oversized declared body must not be read');
- },
- },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_response_size_invalid',
- );
- assert.equal(knownLengthBodyRead, false, 'oversized declared response is rejected before body allocation');
-
- let streamedReads = 0;
- let streamedCancelled = false;
- globalThis.fetch = async () => ({
- ok: true,
- status: 200,
- headers: new Headers(),
- body: {
- getReader() {
- return {
- async read() {
- streamedReads += 1;
- if (streamedReads === 1) {
- return { done: false, value: new Uint8Array(1024 * 1024 + 1) };
- }
- throw new Error('reader must stop after the first oversized chunk');
- },
- async cancel() {
- streamedCancelled = true;
- },
- };
- },
- },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_response_size_invalid',
- );
- assert.equal(streamedReads, 1, 'stream reader stops as soon as the response exceeds the byte budget');
- assert.equal(streamedCancelled, true, 'oversized response stream is cancelled');
-
- globalThis.fetch = async () => new Response(JSON.stringify({ error: {} }), {
- status: 503,
- headers: { 'content-type': 'application/json' },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_provider_rejected',
- );
-
- globalThis.fetch = async () => new Response(JSON.stringify({ choices: [] }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_response_invalid',
- );
-
- globalThis.fetch = undefined;
- await assert.rejects(
- configured.chat([{ role: 'user', content: 'status' }]),
- (error) => error.code === 'orchestrator_transport_unavailable',
- );
-} finally {
- restoreEnvironment();
-}
-
-console.log('✓ orchestrator production boundary tests passed');