open-design/packages/contracts/tests/package-runtime.test.ts
Tom Huang 56bf6ee1b6
feat: agent-callable research command and /search (#615)
* feat: pre-generation research (Tavily) for grounded generation

Adds an optional pre-generation research step so the agent can produce
slides / prototypes / decks grounded in real sources instead of guessing.

User flow:
  1. Settings -> Tavily Search -> paste API key (or set TAVILY_API_KEY).
  2. Click the new Research button in the chat composer.
  3. On send, the daemon runs a Tavily search, prepends the findings
     as a <research_context> block ahead of the system prompt, and
     spawns the agent. Research progress shows up as status pills in
     the chat stream; the agent cites sources inline as [1]/[2]/...

Phase 1 surface:
  - Single provider (Tavily), single depth ('shallow'), no LLM
    synthesis pass (Tavily's `answer` is the summary).
  - Composer toggle only; no popover / depth picker yet.
  - Reuses the existing `status` SSE agent payload + StatusPill UI
    so no new event variants or renderer code are needed.

Layers touched:
  - contracts: ResearchOptions / Source / Findings DTOs;
    ChatRequest.research; export from index.
  - daemon: apps/daemon/src/research/{index,tavily}.ts orchestrator
    + provider; tavily added to MEDIA_PROVIDERS and ENV_KEYS; hook
    in startChatRun before prompt assembly.
  - web: ChatComposer toggle + ChatSendMeta; threaded through
    ChatPane / ProjectView / streamViaDaemon into ChatRequest.

Side fix (required to land the feature, but useful on its own):
  contracts internal relative imports lacked the `.js` suffix that
  NodeNext module resolution requires. This was already breaking
  `pnpm --filter @open-design/daemon typecheck` on main; without the
  fix, none of the new research types were visible to the daemon.
  All internal contracts imports now carry `.js`.

Spec: specs/current/research-feature.md (phases 2-4 outlined for
follow-up: composer popover, multi-provider, deep recursion, example
skills with research_recommends).

Verified:
  - pnpm --filter @open-design/contracts typecheck/test
  - pnpm --filter @open-design/daemon typecheck (the chokidar
    project-watchers test is a pre-existing flake, unrelated)
  - pnpm --filter @open-design/web typecheck
  - node scripts/verify-media-models.mjs

* fix(daemon): clamp Tavily max_results to 20

Tavily's /search endpoint requires `max_results` in [0, 20]; sending a
larger value (e.g. when `research.depth: "deep"` resolves to 30) returns
400 and `runResearch` silently falls back to no-research. Clamp at the
provider boundary so Phase 2 depth tiers above 20 still produce results
instead of failing the request.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* Remove stale research merge leftovers

* Add agent-callable research search

* Fix Indonesian locale typecheck

* Fix research command invocation edge cases

* Harden slash search prompt expansion

* Honor research source caps in command contract

* Require search reports in design files

* Add research data provider settings

* Wire web research provider fallback order

* Update research provider fallback wording

* Revert "Update research provider fallback wording"

This reverts commit 86fb6001e3.

* Revert "Wire web research provider fallback order"

This reverts commit 4c9e16036b.

* Revert "Add research data provider settings"

This reverts commit 23630d1746.

* Add Dexter and Last30Days research skills

* Add DCF and Last30Days OD skills

* Add Last30Days and Dexter skills

* Resolve research review threads

---------

Co-authored-by: a1chzt <chizblank@gmail.com>
2026-05-08 10:33:44 +08:00

68 lines
2.9 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
function readPackageJson(): {
exports?: Record<string, { default?: string; types?: string }>;
files?: string[];
main?: string;
types?: string;
} {
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
}
function packagePath(target: string): string {
return join(packageRoot, target.replace(/^\.\//, ''));
}
describe('@open-design/contracts package runtime shape', () => {
it('exports built JavaScript instead of TypeScript source files', () => {
const pkg = readPackageJson();
expect(pkg.main).toBe('./dist/index.mjs');
expect(pkg.types).toBe('./dist/index.d.ts');
expect(pkg.files).toEqual(['dist']);
expect(pkg.exports?.['.']?.default).toBe('./dist/index.mjs');
expect(pkg.exports?.['.']?.types).toBe('./dist/index.d.ts');
expect(pkg.exports?.['./api/connectionTest']?.default).toBe('./dist/api/connectionTest.mjs');
expect(pkg.exports?.['./api/connectionTest']?.types).toBe('./dist/api/connectionTest.d.ts');
expect(pkg.exports?.['./api/research']?.default).toBe('./dist/api/research.mjs');
expect(pkg.exports?.['./api/research']?.types).toBe('./dist/api/research.d.ts');
expect(pkg.exports?.['./critique']?.default).toBe('./dist/critique.mjs');
expect(pkg.exports?.['./critique']?.types).toBe('./dist/critique.d.ts');
});
it('points every runtime export at generated files', async () => {
const pkg = readPackageJson();
const exports = Object.entries(pkg.exports ?? {});
expect(exports.length).toBeGreaterThan(0);
for (const [_name, target] of exports) {
expect(target.default).toMatch(/^\.\/dist\/.+\.mjs$/);
expect(target.types).toMatch(/^\.\/dist\/.+\.d\.ts$/);
await expect(access(packagePath(target.default!))).resolves.toBeUndefined();
await expect(access(packagePath(target.types!))).resolves.toBeUndefined();
}
});
it('makes runtime exports importable through package exports', async () => {
const contracts = await import('@open-design/contracts');
const connectionTest = await import('@open-design/contracts/api/connectionTest');
const research = await import('@open-design/contracts/api/research');
const critique = await import('@open-design/contracts/critique');
expect(contracts.composeSystemPrompt).toEqual(expect.any(Function));
expect(contracts.exampleHealthResponse).toEqual({ ok: true, service: 'daemon' });
expect(Object.keys(connectionTest)).toEqual([]);
expect(research.RESEARCH_DEFAULT_MAX_SOURCES.shallow).toBe(5);
expect(critique.defaultCritiqueConfig()).toMatchObject({
enabled: false,
protocolVersion: critique.CRITIQUE_PROTOCOL_VERSION,
});
});
});