diff --git a/packages/dmoss-agent/src/cli/attachments.ts b/packages/dmoss-agent/src/cli/attachments.ts index bf8ae26..4b16041 100644 --- a/packages/dmoss-agent/src/cli/attachments.ts +++ b/packages/dmoss-agent/src/cli/attachments.ts @@ -214,7 +214,6 @@ export function preparePromptAttachments( const imageMime = IMAGE_MIME_BY_EXT[ext]; const filename = path.basename(absPath); const label = relativeLabel(absPath, cwd); - const index = nextIndex; if (imageMime) { if (stat.size === 0) { @@ -235,6 +234,7 @@ export function preparePromptAttachments( continue; } const data = buffer.toString('base64'); + const index = nextIndex; attachments.push({ index, kind: 'image', path: absPath, label, filename, mimeType: detectedMime, bytes: stat.size }); blocks.push({ type: 'text', text: attachmentTextHeader('Image', index, label) }); blocks.push({ type: 'image', data, mimeType: detectedMime, filename }); @@ -251,6 +251,7 @@ export function preparePromptAttachments( continue; } const text = fs.readFileSync(absPath, 'utf8'); + const index = nextIndex; attachments.push({ index, kind: 'file', path: absPath, label, filename, mimeType: 'text/plain', bytes: stat.size }); blocks.push({ type: 'text', diff --git a/packages/dmoss-agent/src/cli/community-auth.ts b/packages/dmoss-agent/src/cli/community-auth.ts index 8979c05..d136bc9 100644 --- a/packages/dmoss-agent/src/cli/community-auth.ts +++ b/packages/dmoss-agent/src/cli/community-auth.ts @@ -91,7 +91,8 @@ export function resolveCommunityAuthSessionPath(configDir = resolveConfigDir()): return path.join(configDir, 'community-auth.json'); } -function normalizePortalToken(raw: unknown): string { +/** @internal exported for tests — normalizes a pasted portal token. */ +export function normalizePortalToken(raw: unknown): string { if (typeof raw !== 'string') return ''; let token = raw.trim(); if (!token) return ''; @@ -103,7 +104,7 @@ function normalizePortalToken(raw: unknown): string { } } token = token.replace(/^Bearer\s+/i, '').trim(); - token = token.replace(/\s+/g, '+'); + token = token.replace(/\s+/g, ''); return token.length >= 8 ? token : ''; } diff --git a/packages/dmoss-agent/src/cli/input/vim.ts b/packages/dmoss-agent/src/cli/input/vim.ts index db9e630..25b5282 100644 --- a/packages/dmoss-agent/src/cli/input/vim.ts +++ b/packages/dmoss-agent/src/cli/input/vim.ts @@ -77,7 +77,7 @@ export function handleVimKey(key: string, cursorPos: number, lineLength: number) } // ── Digit accumulation for count prefix (in normal mode) ── - if (vimState.mode === 'normal' && /^[1-9]$/.test(key)) { + if (vimState.mode === 'normal' && /^[0-9]$/.test(key)) { // Allow "0" only if we already have a prefix if (key === '0' && vimState.countPrefix === '') return { type: 'none' }; vimState.countPrefix += key; @@ -252,6 +252,7 @@ export function getVimModeIndicator(): string { case 'normal': return 'NORMAL'; case 'insert': return 'INSERT'; case 'visual': return 'VISUAL'; + default: return 'INSERT'; } } @@ -261,5 +262,6 @@ export function getVimModeColor(): string { case 'normal': return '#38bdf8'; // blue case 'insert': return '#22c55e'; // green case 'visual': return '#a78bfa'; // purple + default: return '#22c55e'; } } diff --git a/packages/dmoss-agent/src/cli/print.ts b/packages/dmoss-agent/src/cli/print.ts index fd782ab..5fd00a5 100644 --- a/packages/dmoss-agent/src/cli/print.ts +++ b/packages/dmoss-agent/src/cli/print.ts @@ -275,21 +275,23 @@ export function isHeadlessResultError(event: HeadlessResultEvent): boolean { return event.is_error; } -function safeJson(value: unknown): string { +function safeJson(value: unknown, state?: HeadlessPrintState): string { try { return JSON.stringify(value); } catch { - return JSON.stringify({ + // Emit a result event with actual state values, not placeholders + const fallbackResult = { type: 'result', subtype: 'error_during_execution', is_error: true, - result: '', - duration_ms: 0, - num_turns: 0, - session_id: '', + result: state?.finalText ?? '', + duration_ms: state ? Math.max(0, Date.now() - state.startTime) : 0, + num_turns: state?.numTurns ?? 0, + session_id: state?.sessionId ?? 'unknown', total_cost_usd: 0, - error: 'unserializable output', - }); + error: 'failed to serialize event to JSON', + }; + return JSON.stringify(fallbackResult); } } diff --git a/packages/dmoss-agent/src/cli/tui.ts b/packages/dmoss-agent/src/cli/tui.ts index 74857db..8b13d95 100644 --- a/packages/dmoss-agent/src/cli/tui.ts +++ b/packages/dmoss-agent/src/cli/tui.ts @@ -3638,7 +3638,7 @@ export function DmossTui({ agent, skillLearner, runtime, sessionKey: initialSess } : picker); return; } - if (/^\d$/.test(inputChar)) { + if (/^[1-9]$/.test(inputChar)) { const selected = Number.parseInt(inputChar, 10) - 1; const choice = choices[selected]; if (choice) { diff --git a/packages/dmoss-agent/src/mcp/mcp-client.ts b/packages/dmoss-agent/src/mcp/mcp-client.ts index efa7011..83a328d 100644 --- a/packages/dmoss-agent/src/mcp/mcp-client.ts +++ b/packages/dmoss-agent/src/mcp/mcp-client.ts @@ -270,6 +270,21 @@ class McpServerConnection { } this.pending.clear(); }); + + // A broken stdin pipe (the child closed its read end / already exited) is + // surfaced asynchronously as an 'error' event on the writable stream. With + // no listener Node escalates it to an uncaught exception that kills the + // whole process — observed as `write EPIPE` on Linux, while macOS silently + // discards the write. Absorb it and fail any in-flight requests; once stdin + // breaks the connection can no longer send, so it is effectively closed. + this.process.stdin!.on('error', (err) => { + this.closed = true; + for (const [, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new DmossError({ code: ErrorCode.MCP_CONNECTION_FAILED, message: `MCP server ${serverName} stdin error: ${err instanceof Error ? err.message : String(err)}` })); + } + this.pending.clear(); + }); } private processBuffer(): void { @@ -355,7 +370,13 @@ class McpServerConnection { notify(method: string, params?: unknown): void { if (this.closed) return; const msg: JsonRpcNotification = { jsonrpc: '2.0', method, params }; - this.process.stdin!.write(JSON.stringify(msg) + '\n'); + try { + this.process.stdin!.write(JSON.stringify(msg) + '\n'); + } catch { + // Best-effort notification: writing to an already-destroyed stdin throws + // synchronously (ERR_STREAM_DESTROYED). Async pipe errors are handled by + // the stdin 'error' listener; nothing here needs the failure to surface. + } } async initialize(): Promise { diff --git a/packages/dmoss-agent/src/provider/anthropic.ts b/packages/dmoss-agent/src/provider/anthropic.ts index 8e47ad6..e69be0c 100644 --- a/packages/dmoss-agent/src/provider/anthropic.ts +++ b/packages/dmoss-agent/src/provider/anthropic.ts @@ -279,7 +279,12 @@ export class AnthropicLLMProvider implements LLMProvider { const block = content[currentToolBlock]; if (block?.type === 'tool_use' && toolInputJson) { try { - (block as { type: 'tool_use'; input: Record }).input = JSON.parse(toolInputJson); + const parsed = JSON.parse(toolInputJson); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + (block as { type: 'tool_use'; input: Record }).input = parsed as Record; + } else { + throw new Error(`Expected object, got ${typeof parsed === 'object' && Array.isArray(parsed) ? 'array' : typeof parsed}`); + } } catch (err) { throw new DmossError({ code: ErrorCode.PROVIDER_UPSTREAM_ERROR, diff --git a/packages/dmoss-agent/src/tools/browser-tools.ts b/packages/dmoss-agent/src/tools/browser-tools.ts index ad1621e..f8d90fa 100644 --- a/packages/dmoss-agent/src/tools/browser-tools.ts +++ b/packages/dmoss-agent/src/tools/browser-tools.ts @@ -211,7 +211,13 @@ async function withBrowser( return `${toolName} 未执行: ${err instanceof Error ? err.message : String(err)}`; } finally { try { - await browser?.close(); + if (browser) { + const closePromise = browser.close(); + const timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, 2_000); + }); + await Promise.race([closePromise, timeoutPromise]); + } } catch { /* best effort */ } diff --git a/packages/dmoss-agent/src/tools/web-fetch.ts b/packages/dmoss-agent/src/tools/web-fetch.ts index 61f8fe6..0dd211f 100644 --- a/packages/dmoss-agent/src/tools/web-fetch.ts +++ b/packages/dmoss-agent/src/tools/web-fetch.ts @@ -509,7 +509,8 @@ export function createWebFetchTool(opts: WebFetchOptions = {}): Tool<{ url: stri }); } redirectCount++; - if (blockPrivate) { + const redirectPrivateWaived = blockPrivate && resolveAllowPrivate().some((p) => hostMatches(nextUrl.hostname, p)); + if (blockPrivate && !redirectPrivateWaived) { verifiedIp = await resolveHostIp(nextUrl.hostname, resolveAddresses); if (verifiedIp === null) { res.body?.cancel?.(); diff --git a/packages/dmoss-agent/test/community-auth-token-normalize.spec.mjs b/packages/dmoss-agent/test/community-auth-token-normalize.spec.mjs new file mode 100644 index 0000000..281d32e --- /dev/null +++ b/packages/dmoss-agent/test/community-auth-token-normalize.spec.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * normalizePortalToken must STRIP internal whitespace from a pasted token, not + * replace it with '+'. A line-wrapped paste (header.\npayload.\nsignature) was + * being turned into header.+payload.+signature — an invalid JWT. + * + * Run: + * npm run build -w @rdk-moss/agent + * node packages/dmoss-agent/test/community-auth-token-normalize.spec.mjs + */ +import assert from 'node:assert/strict'; +import { normalizePortalToken } from '../dist/cli/community-auth.js'; + +// A line-wrapped token must rejoin WITHOUT inserting '+' characters. +assert.equal( + normalizePortalToken('aaaaaaaa.\nbbbbbbbb.\ncccccccc'), + 'aaaaaaaa.bbbbbbbb.cccccccc', + 'line breaks inside a token must be removed, not turned into "+"', +); +assert.doesNotMatch(normalizePortalToken('aaaaaaaa.\nbbbbbbbb'), /\+/, 'no "+" may be introduced'); + +// Bearer prefix + surrounding whitespace is stripped. +assert.equal(normalizePortalToken(' Bearer abcdefghij '), 'abcdefghij'); + +// Tabs/spaces between segments are removed too. +assert.equal(normalizePortalToken('abcd\t efgh \r\n ijkl'), 'abcdefghijkl'); + +// Too-short tokens normalize to empty. +assert.equal(normalizePortalToken('abc'), ''); +assert.equal(normalizePortalToken(undefined), ''); + +console.log('[PASS] normalizePortalToken strips whitespace instead of inserting "+"'); diff --git a/packages/dmoss-agent/test/run-process-abort-cleanup.spec.mjs b/packages/dmoss-agent/test/run-process-abort-cleanup.spec.mjs index bd6f908..28c09f0 100644 --- a/packages/dmoss-agent/test/run-process-abort-cleanup.spec.mjs +++ b/packages/dmoss-agent/test/run-process-abort-cleanup.spec.mjs @@ -6,9 +6,10 @@ import { ProcessError } from '../dist/utils/run-process.js'; // Test that aborting a process works correctly and cleanup is idempotent. const controller = new AbortController(); -// Start a process that will sleep -const promise = runProcess('/bin/sh', { - args: ['-c', 'sleep 10'], +// Start a long-running child via the Node binary (present on every platform — +// '/bin/sh' does not exist on Windows and would ENOENT before the abort fires). +const promise = runProcess(process.execPath, { + args: ['-e', 'setTimeout(() => {}, 10000)'], signal: controller.signal, }); diff --git a/packages/dmoss-agent/test/vim-count-digit-zero.spec.mjs b/packages/dmoss-agent/test/vim-count-digit-zero.spec.mjs new file mode 100644 index 0000000..5381f88 --- /dev/null +++ b/packages/dmoss-agent/test/vim-count-digit-zero.spec.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Vim normal-mode count prefix must accept '0' as a non-leading digit, so a + * count like 10 works. The digit-accumulation guard used /^[1-9]$/, which made + * '0' fall through to the line-start motion even mid-count. + * + * Run: + * npm run build -w @rdk-moss/agent + * node packages/dmoss-agent/test/vim-count-digit-zero.spec.mjs + */ +import assert from 'node:assert/strict'; + +// handleVimKey is gated on isVimEnabled() (DMOSS_VIM_MODE=1); enable before import-side use. +process.env.DMOSS_VIM_MODE = '1'; +const { handleVimKey, getVimState, setVimMode } = await import('../dist/cli/input/vim.js'); + +setVimMode('normal'); +// A bare '0' is the line-start motion, not the start of a count. +handleVimKey('0', 5, 10); +assert.equal(getVimState().countPrefix, '', "'0' with no prefix is a motion, not a count"); + +// '0' after a non-zero digit must accumulate into a multi-digit count (10). +handleVimKey('1', 5, 10); +assert.equal(getVimState().countPrefix, '1', "'1' starts a count"); +handleVimKey('0', 5, 10); +assert.equal(getVimState().countPrefix, '10', "'0' must accumulate into the count to form 10"); + +console.log('[PASS] vim count prefix accepts 0 as a non-leading digit'); diff --git a/packages/dmoss-memory/src/memory-manager.ts b/packages/dmoss-memory/src/memory-manager.ts index d7fa41b..7fd28ea 100644 --- a/packages/dmoss-memory/src/memory-manager.ts +++ b/packages/dmoss-memory/src/memory-manager.ts @@ -366,7 +366,8 @@ export class MemoryManager { const tokens = content.toLowerCase().match(/[a-z0-9一-鿿]+/g) ?? []; const terms = new Set(); for (const token of tokens) { - for (let len = 2; len <= token.length; len++) { + const maxNgramLen = Math.min(6, token.length); + for (let len = 2; len <= maxNgramLen; len++) { for (let start = 0; start <= token.length - len; start++) { terms.add(token.slice(start, start + len)); } diff --git a/packages/dmoss-memory/test/memory-manager-ngrams.spec.mjs b/packages/dmoss-memory/test/memory-manager-ngrams.spec.mjs new file mode 100644 index 0000000..a95a22b --- /dev/null +++ b/packages/dmoss-memory/test/memory-manager-ngrams.spec.mjs @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { MemoryManager } from '../dist/memory-manager.js'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; + +test('extractTerms limits n-gram generation on long tokens', async () => { + const testDir = join(tmpdir(), `memory-ngram-${Date.now()}`); + const manager = new MemoryManager(testDir); + + const longToken = 'a'.repeat(1000); + const terms = manager.extractTerms(longToken); + + assert(terms.length < 10000, + `Terms should be capped, got ${terms.length}`); + + rmSync(testDir, { recursive: true, force: true }); +}); diff --git a/packages/dmoss-skills/src/skill-learner.ts b/packages/dmoss-skills/src/skill-learner.ts index be240ec..7a7d99d 100644 --- a/packages/dmoss-skills/src/skill-learner.ts +++ b/packages/dmoss-skills/src/skill-learner.ts @@ -335,8 +335,6 @@ export class SkillLearner { const idx = callIdToIndex.get(useId); if (idx !== undefined) { calls[idx].failed = true; - } else if (calls.length > 0) { - calls[calls.length - 1].failed = true; } } } diff --git a/packages/dmoss-skills/test/skill-learner-error-tracking.spec.mjs b/packages/dmoss-skills/test/skill-learner-error-tracking.spec.mjs new file mode 100644 index 0000000..5dc244c --- /dev/null +++ b/packages/dmoss-skills/test/skill-learner-error-tracking.spec.mjs @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { SkillLearner } from '../dist/skill-learner.js'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; + +test('extractToolCalls does not falsely attribute unmatched errors to last call', async () => { + const testDir = join(tmpdir(), `skills-${Date.now()}`); + const learner = new SkillLearner({ skillsDir: testDir }); + + const messages = [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'call_1', name: 'exec', input: {} }, + { type: 'tool_use', id: 'call_2', name: 'read', input: {} }, + { type: 'tool_use', id: 'call_3', name: 'write', input: {} }, + ] + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call_1', is_error: false }, + { type: 'tool_result', tool_use_id: 'unknown_id_1', is_error: true }, + { type: 'tool_result', tool_use_id: 'call_3', is_error: false }, + ] + } + ]; + + const toolCalls = learner.extractToolCalls(messages); + + assert.strictEqual(toolCalls[0].failed, false, 'exec succeeded'); + assert.strictEqual(toolCalls[1].failed, false, 'read should not be marked failed'); + assert.strictEqual(toolCalls[2].failed, false, 'write succeeded'); + + rmSync(testDir, { recursive: true, force: true }); +}); diff --git a/packages/dmoss/src/contracts/async-task.ts b/packages/dmoss/src/contracts/async-task.ts index c93e072..602860a 100644 --- a/packages/dmoss/src/contracts/async-task.ts +++ b/packages/dmoss/src/contracts/async-task.ts @@ -295,7 +295,7 @@ export class InMemoryMossAsyncTaskRegistry implements MossAsyncTaskRegistry { this.complete(record, { status: 'failed', success: false, - summary: result.summary, + summary: result.summary || 'task failed', error: result.summary || 'task failed', data: result.data, }); diff --git a/packages/dmoss/src/contracts/host-adapter.ts b/packages/dmoss/src/contracts/host-adapter.ts index 5ea6594..50f90cb 100644 --- a/packages/dmoss/src/contracts/host-adapter.ts +++ b/packages/dmoss/src/contracts/host-adapter.ts @@ -574,12 +574,13 @@ function validateMossCapabilityCoverageShape( return `manifest.capabilityCoverage[].tools references unknown tool: ${entry.id} -> ${toolName}`; } const toolSurface = tool.surface; - if ( - entrySurfaces.size > 0 && - isOneOf(toolSurface, MOSS_HOST_TOOL_SURFACE_KINDS) && - !entrySurfaces.has(toolSurface) - ) { - return `manifest.capabilityCoverage[].tools references tool from different surface: ${entry.id} -> ${toolName} (${tool.surface})`; + if (entrySurfaces.size > 0) { + if (!isOneOf(toolSurface, MOSS_HOST_TOOL_SURFACE_KINDS)) { + return `manifest.capabilityCoverage[].tools references tool without declared surface: ${entry.id} -> ${toolName}`; + } + if (!entrySurfaces.has(toolSurface)) { + return `manifest.capabilityCoverage[].tools references tool from different surface: ${entry.id} -> ${toolName} (${tool.surface})`; + } } } } diff --git a/packages/dmoss/test/async-task-summary-consistency.spec.mjs b/packages/dmoss/test/async-task-summary-consistency.spec.mjs new file mode 100644 index 0000000..a27d48d --- /dev/null +++ b/packages/dmoss/test/async-task-summary-consistency.spec.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Test for async task summary consistency bug fix + */ + +import assert from 'node:assert/strict'; +import { createInMemoryMossAsyncTaskRegistry } from '../dist/contracts/async-task.js'; + +let passed = 0; +let total = 0; + +/* ---- Test: Empty summary fallback ---- */ + +total++; +{ + const registry = createInMemoryMossAsyncTaskRegistry(); + const runner = async () => ({ + success: false, + summary: '', + data: null, + }); + + registry.start({ + taskId: 'empty-summary-test', + kind: 'host_task', + payload: {}, + }, runner); + + const completion = await registry.wait('empty-summary-test'); + + assert.equal(completion.status, 'failed'); + assert.equal(completion.success, false); + // Both summary and error should have the fallback value + assert.equal(completion.summary, 'task failed'); + assert.equal(completion.error, 'task failed'); + console.log(' [PASS] empty summary gets fallback value'); + passed++; +} + +/* ---- Test: Non-empty summary preserved ---- */ + +total++; +{ + const registry = createInMemoryMossAsyncTaskRegistry(); + const runner = async () => ({ + success: false, + summary: 'Custom failure message', + data: null, + }); + + registry.start({ + taskId: 'custom-summary-test', + kind: 'host_task', + payload: {}, + }, runner); + + const completion = await registry.wait('custom-summary-test'); + + assert.equal(completion.status, 'failed'); + assert.equal(completion.summary, 'Custom failure message'); + assert.equal(completion.error, 'Custom failure message'); + console.log(' [PASS] custom summary preserved'); + passed++; +} + +/* ---- Test: Success path consistency ---- */ + +total++; +{ + const registry = createInMemoryMossAsyncTaskRegistry(); + const runner = async () => ({ + success: true, + summary: 'Task completed', + data: { result: 'data' }, + }); + + registry.start({ + taskId: 'success-test', + kind: 'host_task', + payload: {}, + }, runner); + + const completion = await registry.wait('success-test'); + + assert.equal(completion.status, 'completed'); + assert.equal(completion.success, true); + assert.equal(completion.summary, 'Task completed'); + assert.deepEqual(completion.data, { result: 'data' }); + console.log(' [PASS] success path summary handling'); + passed++; +} + +console.log(`\n${passed}/${total} async task summary tests passed`); +process.exit(passed === total ? 0 : 1); diff --git a/packages/dmoss/test/core-contracts-bugs.spec.mjs b/packages/dmoss/test/core-contracts-bugs.spec.mjs new file mode 100644 index 0000000..0b34c9a --- /dev/null +++ b/packages/dmoss/test/core-contracts-bugs.spec.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * Bug hunt tests for core-contracts subsystem + * + * Covers: + * - Capability coverage surface validation + * - Async task summary field consistency + */ + +import assert from 'node:assert/strict'; +import { + evaluateMossHostCompatibility, +} from '../dist/contracts/host-adapter.js'; +import { + createInMemoryMossAsyncTaskRegistry, +} from '../dist/contracts/async-task.js'; + +const baseManifest = { + schema: 'moss_host_adapter.v1', + contractVersion: 1, + host: { id: 'test', name: 'Test', version: '1.0.0' }, + moss: { version: '1.0.0', packages: [] }, + capabilities: [], + providers: [], + tools: [ + { + name: 'with_surface', + boundaryId: 'b1', + sideEffectClass: 'readonly', + approval: 'not_required', + source: 'host', + surface: 'computer_workspace', + }, + { + name: 'without_surface', + boundaryId: 'b2', + sideEffectClass: 'readonly', + approval: 'not_required', + source: 'host', + }, + ], + eventSinks: [], + knowledgeModules: [], +}; + +let passed = 0; +let total = 0; + +/* ---- Test 1: Tool without surface in capability coverage ---- */ + +total++; +{ + const manifest = { + ...baseManifest, + capabilityCoverage: [ + { + id: 'test-cap', + priority: 'P0', + status: 'covered', + userOutcome: 'Test', + surface: 'computer_workspace', + tools: ['without_surface'], + evidence: ['test.ts'], + gaps: [], + rationale: 'Test', + }, + ], + }; + + const result = evaluateMossHostCompatibility(manifest); + assert.equal(result.status, 'invalid_manifest'); + assert.equal(result.compatible, false); + assert.ok(result.reasons[0].includes('without declared surface')); + console.log(' [PASS] tool without surface rejected in capability coverage'); + passed++; +} + +/* ---- Test 2: Tool without surface in capability with surfaces array ---- */ + +total++; +{ + const manifest = { + ...baseManifest, + capabilityCoverage: [ + { + id: 'multi-surface-cap', + priority: 'P0', + status: 'covered', + userOutcome: 'Test', + surfaces: ['computer_workspace', 'browser_web'], + tools: ['without_surface'], + evidence: ['test.ts'], + gaps: [], + rationale: 'Test', + }, + ], + }; + + const result = evaluateMossHostCompatibility(manifest); + assert.equal(result.status, 'invalid_manifest'); + assert.equal(result.compatible, false); + assert.ok(result.reasons[0].includes('without declared surface')); + console.log(' [PASS] tool without surface rejected in multi-surface capability'); + passed++; +} + +/* ---- Test 3: Async task summary fallback on failure ---- */ + +total++; +{ + const registry = createInMemoryMossAsyncTaskRegistry(); + const runner = async () => ({ + success: false, + summary: '', + data: null, + }); + + registry.start({ + taskId: 'async-test', + kind: 'host_task', + payload: {}, + }, runner); + + const completion = await registry.wait('async-test'); + assert.equal(completion.status, 'failed'); + assert.ok(completion.summary.length > 0 || completion.error.length > 0); + // Both should have content or be consistent + if (completion.summary === '' && completion.error !== '') { + // This is currently the bug - summary empty but error has fallback + // After fix, summary should also have fallback + assert.ok(completion.error === 'task failed'); + } + console.log(' [PASS] async task failure summary handling'); + passed++; +} + +console.log(`\n${passed}/${total} core-contracts tests passed`); +process.exit(passed === total ? 0 : 1);