feat: add manual export provider and ReviewRequest pattern
Implement Task 8.0 — Manual Export Provider with full provider abstraction:
Source files:
- src/providers/manual-export.js (104 lines) — new provider that wraps
pre-built prompts in copy-ready format for ChatGPT Web/Business use
- src/providers/factory.js — register 'manual' provider in whitelist
- src/providers/openai.js — update JSDoc for ProviderRequest parameter
All 5 handlers updated with ReviewRequest pattern:
- src/tools/ask-chatgpt.js
- src/tools/debug-issue.js
- src/tools/review-code.js
- src/tools/review-plan.js
- src/tools/architecture-review.js
Each handler now packages shared data as { prompt, input } between step 5
and 6 of the orchestration flow.
Tests:
- test/providers/manual-export.test.js (56 tests) — covers structure,
copy-ready box formatting, tool name detection, long prompts, Unicode,
edge cases, provider contract compliance, idempotency
- test/providers/factory.test.js (+9 tests) — manual provider whitelist,
factory routing, send delegation
Docs:
- ARCHITECTURE.md — provider selection table with openai/manual values
- README.md — provider comparison table and manual workflow description
This commit is contained in:
+10
-3
@@ -439,10 +439,17 @@ src/openai/responses.js — sendOpenAIResponse(client, params) API call wra
|
|||||||
Configured via `CHATGPT_MCP_PROVIDER` env var (defaults to `"openai"`):
|
Configured via `CHATGPT_MCP_PROVIDER` env var (defaults to `"openai"`):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
CHATGPT_MCP_PROVIDER=openai # current default; accepts any string but factory validates at runtime
|
CHATGPT_MCP_PROVIDER=openai # or "manual" for copy-paste workflow
|
||||||
```
|
```
|
||||||
|
|
||||||
The factory whitelists `"openai"`. Any unrecognized value throws at provider creation time (not config load time). Null/NaN/falsy values default to `"openai"` for safe fallback.
|
Supported provider values:
|
||||||
|
|
||||||
|
| Value | Description | Requires API key? |
|
||||||
|
| -------- | -------------------------------------------------------------- | ----------------- |
|
||||||
|
| `openai` | Default — calls ChatGPT via OpenAI API | Yes |
|
||||||
|
| `manual` | Copy-paste — wraps prompts in a ready-to-copy format | No |
|
||||||
|
|
||||||
|
The factory whitelists `"openai"` and `"manual"`. Any unrecognized value throws at provider creation time (not config load time). Null/NaN/falsy values default to `"openai"` for safe fallback.
|
||||||
|
|
||||||
### OpenAI-specific Environment Variables
|
### OpenAI-specific Environment Variables
|
||||||
|
|
||||||
@@ -453,7 +460,7 @@ OPENAI_TEMPERATURE=0.2
|
|||||||
OPENAI_MAX_OUTPUT_TOKENS=2000
|
OPENAI_MAX_OUTPUT_TOKENS=2000
|
||||||
```
|
```
|
||||||
|
|
||||||
The provider pattern makes it straightforward to add other providers (Ollama, Anthropic) — implement the `send(input, config)` interface and register it in the factory's whitelist.
|
The provider pattern makes it straightforward to add other providers (Ollama, Anthropic, custom) — implement the `send(request, config)` interface and register it in the factory's whitelist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ OpenAI Provider → OpenAI Responses API
|
|||||||
Advisory Response
|
Advisory Response
|
||||||
```
|
```
|
||||||
|
|
||||||
The provider layer is configurable via `CHATGPT_MCP_PROVIDER` env var. Currently only `"openai"` is supported, but the factory pattern enables future providers without touching tool handlers.
|
The provider layer is configurable via `CHATGPT_MCP_PROVIDER` env var. Two providers are available:
|
||||||
|
|
||||||
|
| Value | Description | Use case |
|
||||||
|
| -------- | -------------------------------------------------------------- | ------------------------------------------- |
|
||||||
|
| `openai` | Default — calls ChatGPT via OpenAI API | Automated second-opinion queries |
|
||||||
|
| `manual` | Copy-paste — wraps prompts in a ready-to-copy format | Manual ChatGPT Web/Business as advisor |
|
||||||
|
|
||||||
|
For the **manual** provider, set `CHATGPT_MCP_PROVIDER=manual`. Each tool call returns a copy-ready prompt block you can paste into ChatGPT Web or ChatGPT Business. This turns Claude Code into an orchestrator: it builds the perfect prompt and formats it for you to hand off to ChatGPT as a second-opinion advisor — all without API calls, quotas, or cost.
|
||||||
|
|
||||||
|
The factory pattern enables future providers (Ollama, Anthropic, custom) without touching tool handlers.
|
||||||
|
|
||||||
All responses are advisory only.
|
All responses are advisory only.
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
// Returns a chat provider based on config value CHATGPT_MCP_PROVIDER.
|
// Returns a chat provider based on config value CHATGPT_MCP_PROVIDER.
|
||||||
|
|
||||||
import { openaiProvider } from "./openai.js";
|
import { openaiProvider } from "./openai.js";
|
||||||
|
import { manualExportProvider } from "./manual-export.js";
|
||||||
|
|
||||||
const SUPPORTED_PROVIDERS = new Set(["openai"]);
|
const SUPPORTED_PROVIDERS = new Set(["openai", "manual"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a chat provider from configuration.
|
* Create a chat provider from configuration.
|
||||||
* Defaults to OpenAI if no provider is specified or value is invalid.
|
* Defaults to OpenAI if no provider is specified or value is invalid.
|
||||||
* @param {object} config - Loaded config object (must contain openaiApiKey).
|
* @param {object} config - Loaded config object (must contain openaiApiKey).
|
||||||
* @param {string} config.chatgptMcpProvider - Provider name (default: "openai").
|
* @param {string} config.chatgptMcpProvider - Provider name (default: "openai").
|
||||||
* @returns {{ send: (input: object, cfg: object) => Promise<{ content: string }> }} Chat provider.
|
* @returns {{ send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }} Chat provider.
|
||||||
|
* ProviderRequest = { prompt?: string, input?: object }
|
||||||
*/
|
*/
|
||||||
export function createChatProvider(config) {
|
export function createChatProvider(config) {
|
||||||
const providerName = config?.chatgptMcpProvider || "openai";
|
const providerName = config?.chatgptMcpProvider || "openai";
|
||||||
@@ -24,6 +26,8 @@ export function createChatProvider(config) {
|
|||||||
switch (providerName) {
|
switch (providerName) {
|
||||||
case "openai":
|
case "openai":
|
||||||
return openaiProvider;
|
return openaiProvider;
|
||||||
|
case "manual":
|
||||||
|
return manualExportProvider;
|
||||||
default:
|
default:
|
||||||
// Should not reach here because of the set check above.
|
// Should not reach here because of the set check above.
|
||||||
throw new Error(`Unknown provider "${providerName}".`);
|
throw new Error(`Unknown provider "${providerName}".`);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Manual Export chat provider adapter.
|
||||||
|
// Wraps the pre-built prompt into a copy-ready format for manual
|
||||||
|
// use in ChatGPT Web, ChatGPT Business, or Claude Code.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect which MCP tool produced this prompt from available input fields.
|
||||||
|
* Uses heuristic field detection — best-effort when only { prompt } is present.
|
||||||
|
* @param {object} request - ProviderRequest with optional { input } field.
|
||||||
|
* @returns {string} Lowercase tool name (e.g., "ask_chatgpt", "review_plan").
|
||||||
|
*/
|
||||||
|
function detectToolName(request) {
|
||||||
|
const input = request?.input;
|
||||||
|
|
||||||
|
if (!input) return "unknown";
|
||||||
|
|
||||||
|
if (input.logs) return "debug_issue";
|
||||||
|
if (input.relevantFiles && input.relevantFiles.some(f => f.path)) return "review_code";
|
||||||
|
if (input.context?.toString().toLowerCase().includes("architecture")) return "architecture_review";
|
||||||
|
|
||||||
|
// Distinguish review_plan from ask_chatgpt: review_plan typically has a projectSummary or taskSummary
|
||||||
|
if (input.projectSummary || input.taskSummary) return "review_plan";
|
||||||
|
|
||||||
|
return "ask_chatgpt";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format prompt output with user instructions for manual copy/paste.
|
||||||
|
* @param {string} prompt - The pre-built prompt from the prompt builder.
|
||||||
|
* @param {string} toolName - Detected or default tool name.
|
||||||
|
* @returns {string} Formatted manual export content.
|
||||||
|
*/
|
||||||
|
function formatManualExport(prompt, toolName) {
|
||||||
|
const length = prompt.length.toLocaleString();
|
||||||
|
const lines = [
|
||||||
|
"═══════════════════════════════════════════════════",
|
||||||
|
`MANUAL EXPORT — ${toolName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (prompt.length > 30_000) {
|
||||||
|
lines.push("⚠️ NOTE: Prompt is ~" + prompt.length.toLocaleString() + " characters — may approach ChatGPT context limits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("📋 COPY THIS PROMPT INTO ChatGPT WEB / BUSINESS:");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("┌───────────────────────────────────────────────────┐");
|
||||||
|
|
||||||
|
// The prompt itself — displayed as-is for accurate copy/paste
|
||||||
|
const promptLines = prompt.split("\n");
|
||||||
|
for (const pline of promptLines) {
|
||||||
|
lines.push("│ " + pline);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("└───────────────────────────────────────────────────┘");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("📝 INSTRUCTIONS:");
|
||||||
|
lines.push("1. Open https://chat.openai.com (or your ChatGPT Business URL)");
|
||||||
|
lines.push("2. Paste the prompt above into the input box");
|
||||||
|
lines.push("3. Click Send");
|
||||||
|
lines.push("4. Review ChatGPT's second-opinion response");
|
||||||
|
lines.push("5. Compare with Claude Code's analysis — use both perspectives");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("📊 METADATA:");
|
||||||
|
lines.push(`Tool: ${toolName}`);
|
||||||
|
lines.push("Provider: Manual Export (no API calls)");
|
||||||
|
lines.push(`Prompt Length: ${length} characters`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("═══════════════════════════════════════════════════");
|
||||||
|
lines.push("This is advisory output only. Claude Code remains the executor.");
|
||||||
|
lines.push("═══════════════════════════════════════════════════");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat provider that wraps the pre-built prompt for manual copy/paste
|
||||||
|
* into ChatGPT Web, ChatGPT Business, or Claude Code.
|
||||||
|
* Implements { send(request, config) => Promise<{ content: string }> }.
|
||||||
|
*/
|
||||||
|
export const manualExportProvider = {
|
||||||
|
/**
|
||||||
|
* @param {{ prompt?: string, input?: object }} request - ProviderRequest with pre-built prompt.
|
||||||
|
* @param {object} _config - Config (unused — Manual Export is stateless).
|
||||||
|
* @returns {Promise<{ content: string }>} Copy-ready prompt output.
|
||||||
|
*/
|
||||||
|
async send(request, _config) {
|
||||||
|
const prompt = request?.prompt || "";
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
"⚠️ Manual Export Provider received no prompt to export.",
|
||||||
|
"",
|
||||||
|
"This indicates the handler did not pass the built prompt",
|
||||||
|
"in the provider request. Please check your handler",
|
||||||
|
"implementation and ensure reviewRequest includes { prompt }.",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = detectToolName(request);
|
||||||
|
return { content: formatManualExport(prompt, toolName) };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import { sendOpenAIResponse } from "../openai/responses.js";
|
|||||||
*/
|
*/
|
||||||
export const openaiProvider = {
|
export const openaiProvider = {
|
||||||
/**
|
/**
|
||||||
* @param {object} input - validated tool input (post-budget check).
|
* @param {{ prompt?: string, input?: object }} request - ProviderRequest (prompt is unused by OpenAI provider).
|
||||||
* @param {object} config - full config from loadConfig().
|
* @param {object} config - full config from loadConfig().
|
||||||
* @returns {Promise<{ content: string }>}
|
* @returns {Promise<{ content: string }>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { buildArchitectureReviewPrompt } from "../prompts/architecture-review.js
|
|||||||
* Raw tool input per ARCHITECTURE.md §7 schema.
|
* Raw tool input per ARCHITECTURE.md §7 schema.
|
||||||
* @param {{
|
* @param {{
|
||||||
* loadConfig: () => object,
|
* loadConfig: () => object,
|
||||||
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
|
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
|
||||||
|
* ProviderRequest = { prompt?: string, input?: object }
|
||||||
* }} deps
|
* }} deps
|
||||||
* Injected external dependencies.
|
* Injected external dependencies.
|
||||||
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
||||||
@@ -57,11 +58,15 @@ export async function handleArchitectureReview(input, deps) {
|
|||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 5b. Package shared data for provider ---
|
||||||
|
|
||||||
|
const reviewRequest = { prompt: promptMessages, input: budget.input };
|
||||||
|
|
||||||
// --- 6. Send via provider ---
|
// --- 6. Send via provider ---
|
||||||
|
|
||||||
let aiResult;
|
let aiResult;
|
||||||
try {
|
try {
|
||||||
aiResult = await provider.send(budget.input, config);
|
aiResult = await provider.send(reviewRequest, config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { buildAskChatGptPrompt } from "../prompts/ask-chatgpt.js";
|
|||||||
* Raw tool input per ARCHITECTURE.md §7 schema.
|
* Raw tool input per ARCHITECTURE.md §7 schema.
|
||||||
* @param {{
|
* @param {{
|
||||||
* loadConfig: () => object,
|
* loadConfig: () => object,
|
||||||
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
|
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
|
||||||
|
* ProviderRequest = { prompt?: string, input?: object }
|
||||||
* }} deps
|
* }} deps
|
||||||
* Injected external dependencies.
|
* Injected external dependencies.
|
||||||
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
||||||
@@ -57,11 +58,15 @@ export async function handleAskChatGpt(input, deps) {
|
|||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 5b. Package shared data for provider ---
|
||||||
|
|
||||||
|
const reviewRequest = { prompt: promptMessages, input: budget.input };
|
||||||
|
|
||||||
// --- 6. Send via provider ---
|
// --- 6. Send via provider ---
|
||||||
|
|
||||||
let aiResult;
|
let aiResult;
|
||||||
try {
|
try {
|
||||||
aiResult = await provider.send(budget.input, config);
|
aiResult = await provider.send(reviewRequest, config);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { buildDebugIssuePrompt } from "../prompts/debug-issue.js";
|
|||||||
* Raw tool input per ARCHITECTURE.md §7 schema.
|
* Raw tool input per ARCHITECTURE.md §7 schema.
|
||||||
* @param {{
|
* @param {{
|
||||||
* loadConfig: () => object,
|
* loadConfig: () => object,
|
||||||
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
|
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
|
||||||
|
* ProviderRequest = { prompt?: string, input?: object }
|
||||||
* }} deps
|
* }} deps
|
||||||
* Injected external dependencies.
|
* Injected external dependencies.
|
||||||
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
||||||
@@ -57,11 +58,15 @@ export async function handleDebugIssue(input, deps) {
|
|||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 5b. Package shared data for provider ---
|
||||||
|
|
||||||
|
const reviewRequest = { prompt: promptMessages, input: budget.input };
|
||||||
|
|
||||||
// --- 6. Send via provider ---
|
// --- 6. Send via provider ---
|
||||||
|
|
||||||
let aiResult;
|
let aiResult;
|
||||||
try {
|
try {
|
||||||
aiResult = await provider.send(budget.input, config);
|
aiResult = await provider.send(reviewRequest, config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { buildReviewCodePrompt } from "../prompts/review-code.js";
|
|||||||
* Raw tool input per ARCHITECTURE.md §7 schema.
|
* Raw tool input per ARCHITECTURE.md §7 schema.
|
||||||
* @param {{
|
* @param {{
|
||||||
* loadConfig: () => object,
|
* loadConfig: () => object,
|
||||||
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
|
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
|
||||||
|
* ProviderRequest = { prompt?: string, input?: object }
|
||||||
* }} deps
|
* }} deps
|
||||||
* Injected external dependencies.
|
* Injected external dependencies.
|
||||||
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
||||||
@@ -57,11 +58,15 @@ export async function handleReviewCode(input, deps) {
|
|||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 5b. Package shared data for provider ---
|
||||||
|
|
||||||
|
const reviewRequest = { prompt: promptMessages, input: budget.input };
|
||||||
|
|
||||||
// --- 6. Send via provider ---
|
// --- 6. Send via provider ---
|
||||||
|
|
||||||
let aiResult;
|
let aiResult;
|
||||||
try {
|
try {
|
||||||
aiResult = await provider.send(budget.input, config);
|
aiResult = await provider.send(reviewRequest, config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { buildReviewPlanPrompt } from "../prompts/review-plan.js";
|
|||||||
* Raw tool input per ARCHITECTURE.md §7 schema.
|
* Raw tool input per ARCHITECTURE.md §7 schema.
|
||||||
* @param {{
|
* @param {{
|
||||||
* loadConfig: () => object,
|
* loadConfig: () => object,
|
||||||
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
|
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
|
||||||
|
* ProviderRequest = { prompt?: string, input?: object }
|
||||||
* }} deps
|
* }} deps
|
||||||
* Injected external dependencies.
|
* Injected external dependencies.
|
||||||
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
|
||||||
@@ -57,11 +58,15 @@ export async function handleReviewPlan(input, deps) {
|
|||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 5b. Package shared data for provider ---
|
||||||
|
|
||||||
|
const reviewRequest = { prompt: promptMessages, input: budget.input };
|
||||||
|
|
||||||
// --- 6. Send via provider ---
|
// --- 6. Send via provider ---
|
||||||
|
|
||||||
let aiResult;
|
let aiResult;
|
||||||
try {
|
try {
|
||||||
aiResult = await provider.send(budget.input, config);
|
aiResult = await provider.send(reviewRequest, config);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: String(err), warnings: [] };
|
return { ok: false, error: String(err), warnings: [] };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { createChatProvider, openaiProvider } from "../../src/providers/factory.js";
|
import { createChatProvider, openaiProvider } from "../../src/providers/factory.js";
|
||||||
|
import { manualExportProvider } from "../../src/providers/manual-export.js";
|
||||||
|
|
||||||
function mockConfig(provider) {
|
function mockConfig(provider) {
|
||||||
const cfg = { chatgptMcpProvider: provider };
|
const cfg = { chatgptMcpProvider: provider };
|
||||||
@@ -195,3 +196,66 @@ describe("repeatability", () => {
|
|||||||
expect(p1).toBe(p2);
|
expect(p1).toBe(p2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Manual provider ---
|
||||||
|
|
||||||
|
describe("manual provider", () => {
|
||||||
|
it("supports manual provider via createChatProvider", () => {
|
||||||
|
const provider = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
expect(provider.send).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same singleton instance for repeated calls with 'manual'", () => {
|
||||||
|
const p1 = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
const p2 = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
expect(p1).toBe(p2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is different from openai provider", () => {
|
||||||
|
const manualP = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
expect(manualP).not.toBe(openaiProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can detect manual as a supported provider (not throw)", () => {
|
||||||
|
const provider = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
expect(provider.send).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual provider send returns { content: string } shape", async () => {
|
||||||
|
const provider = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
const result = await provider.send(
|
||||||
|
{ prompt: "test prompt from manual env", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result).toHaveProperty("content");
|
||||||
|
expect(typeof result.content).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual provider output contains MANUAL EXPORT marker", async () => {
|
||||||
|
const provider = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
const result = await provider.send({ prompt: "test", input: {} }, {});
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual provider output contains COPY box delimiters", async () => {
|
||||||
|
const provider = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
const result = await provider.send({ prompt: "test", input: {} }, {});
|
||||||
|
expect(result.content).toContain("┌");
|
||||||
|
expect(result.content).toContain("┐");
|
||||||
|
expect(result.content).toContain("┘");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be switched from openai to manual and back", () => {
|
||||||
|
const p1 = createChatProvider({ chatgptMcpProvider: "openai" });
|
||||||
|
const p2 = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
const p3 = createChatProvider({ chatgptMcpProvider: "openai" });
|
||||||
|
expect(p1).toBe(openaiProvider);
|
||||||
|
expect(p2).not.toBe(openaiProvider);
|
||||||
|
expect(p3).toBe(openaiProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual provider is not the same as openaiProvider singleton", () => {
|
||||||
|
const manualP = createChatProvider({ chatgptMcpProvider: "manual" });
|
||||||
|
expect(manualExportProvider).toBe(manualP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { manualExportProvider } from "../../src/providers/manual-export.js";
|
||||||
|
|
||||||
|
// --- Basic structure ---
|
||||||
|
|
||||||
|
describe("basic structure", () => {
|
||||||
|
it("exports a provider with send method", () => {
|
||||||
|
expect(manualExportProvider).toBeDefined();
|
||||||
|
expect(typeof manualExportProvider.send).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { content: string } shape on success", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test prompt" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result).toHaveProperty("content");
|
||||||
|
expect(typeof result.content).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns non-empty content for valid prompt", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test prompt" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic — same input produces same output", async () => {
|
||||||
|
const input = { prompt: "deterministic test", input: { question: "hi" } };
|
||||||
|
const r1 = await manualExportProvider.send(input, {});
|
||||||
|
const r2 = await manualExportProvider.send(input, {});
|
||||||
|
expect(r1.content).toBe(r2.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not modify the request object", async () => {
|
||||||
|
const req = { prompt: "test", input: { question: "hi" } };
|
||||||
|
const original = JSON.stringify(req);
|
||||||
|
await manualExportProvider.send(req, {});
|
||||||
|
expect(JSON.stringify(req)).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not modify the config object", async () => {
|
||||||
|
const config = { openaiApiKey: "sk-test" };
|
||||||
|
await manualExportProvider.send({ prompt: "test" }, config);
|
||||||
|
expect(config.openaiApiKey).toBe("sk-test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Copy-ready structure ---
|
||||||
|
|
||||||
|
describe("copy-ready structure", () => {
|
||||||
|
it("includes MANUAL EXPORT header with tool name", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes COPY box with top-left corner delimiters (┌)", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("┌");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes COPY box with bottom-right corner delimiters (┐ and ┘)", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("┐");
|
||||||
|
expect(result.content).toContain("└");
|
||||||
|
expect(result.content).toContain("┘");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes instructions section with numbered steps", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("📝 INSTRUCTIONS:");
|
||||||
|
expect(result.content).toContain("1.");
|
||||||
|
expect(result.content).toContain("2.");
|
||||||
|
expect(result.content).toContain("3.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes metadata section", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("📊 METADATA:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes provider info in metadata", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Manual Export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes tool name in metadata", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toMatch(/Tool: \w+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes prompt length in metadata with formatted thousands separator", async () => {
|
||||||
|
const longPrompt = "x".repeat(12345);
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: longPrompt, input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Prompt Length:");
|
||||||
|
expect(result.content).toContain("12,345 characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes advisory footer", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("advisory output only");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps each line of prompt in box with │ delimiter", async () => {
|
||||||
|
const multiLine = "line one\nline two\nline three";
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: multiLine, input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("│ line one");
|
||||||
|
expect(result.content).toContain("│ line two");
|
||||||
|
expect(result.content).toContain("│ line three");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Per-tool detection ---
|
||||||
|
|
||||||
|
describe("tool name detection", () => {
|
||||||
|
it("detects debug_issue when input has logs", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { logs: "Error: boom" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Tool: debug_issue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects review_code when input has relevantFiles with paths", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { relevantFiles: [{ path: "src/foo.js" }] } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Tool: review_code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects architecture_review when context includes 'architecture'", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { context: "Architecture decision here" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Tool: architecture_review");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects review_plan when input has projectSummary", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { projectSummary: "Project scope" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Tool: review_plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to ask_chatgpt when no hints available", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test", input: { question: "hi" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Tool: ask_chatgpt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 'unknown' when input is absent in request", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("Tool: unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Long prompt handling ---
|
||||||
|
|
||||||
|
describe("long prompt handling", () => {
|
||||||
|
it("adds warning banner for prompts over 30k characters", async () => {
|
||||||
|
const longPrompt = "x".repeat(31_000);
|
||||||
|
const result = await manualExportProvider.send({ prompt: longPrompt, input: {} }, {});
|
||||||
|
expect(result.content).toContain("⚠️ NOTE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT add warning for prompts under 30k characters", async () => {
|
||||||
|
const validPrompt = "x".repeat(29_999);
|
||||||
|
const result = await manualExportProvider.send({ prompt: validPrompt, input: {} }, {});
|
||||||
|
expect(result.content).not.toContain("⚠️ NOTE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns valid output for very long prompts (>50k chars)", async () => {
|
||||||
|
const veryLongPrompt = "x".repeat(50_000);
|
||||||
|
const result = await manualExportProvider.send({ prompt: veryLongPrompt, input: {} }, {});
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
expect(result.content.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct character count in metadata for long prompts", async () => {
|
||||||
|
const length = 50_000;
|
||||||
|
const result = await manualExportProvider.send({ prompt: "x".repeat(length), input: {} }, {});
|
||||||
|
expect(result.content).toContain(`${length.toLocaleString()} characters`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles prompts at exactly the 30k boundary", async () => {
|
||||||
|
const exactPrompt = "x".repeat(30_000);
|
||||||
|
const result = await manualExportProvider.send({ prompt: exactPrompt, input: {} }, {});
|
||||||
|
expect(result.content).not.toContain("⚠️ NOTE"); // not over 30k, exactly at it
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Unicode and special characters ---
|
||||||
|
|
||||||
|
describe("unicode and special characters", () => {
|
||||||
|
it("preserves Japanese characters in prompt", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "こんにちは世界", input: {} },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("こんにちは世界");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves emoji in prompt", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "🚀 Launch plan 🎯", input: {} },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("🚀");
|
||||||
|
expect(result.content).toContain("🎯");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves markdown code blocks in prompt", async () => {
|
||||||
|
const codePrompt = "```js\nconst x = 1;\n```";
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: codePrompt, input: {} },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("```js");
|
||||||
|
expect(result.content).toContain("const x = 1;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves JSON in prompt", async () => {
|
||||||
|
const jsonPrompt = '{"key": "value", "nested": {"a": 1}}';
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: jsonPrompt, input: {} },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain('"key"');
|
||||||
|
expect(result.content).toContain('"value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves special HTML-like characters", async () => {
|
||||||
|
const htmlPrompt = '<script>alert("xss")</script>';
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: htmlPrompt, input: {} },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain('<script>');
|
||||||
|
expect(result.content).toContain('"xss"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Empty / edge cases ---
|
||||||
|
|
||||||
|
describe("empty and edge case handling", () => {
|
||||||
|
it("returns warning content when prompt is empty string", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "", input: {} }, {});
|
||||||
|
expect(result.content).toContain("received no prompt to export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning content when prompt field is missing from request", async () => {
|
||||||
|
const result = await manualExportProvider.send({}, {});
|
||||||
|
expect(result.content).toContain("received no prompt to export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning content when request is null", async () => {
|
||||||
|
const result = await manualExportProvider.send(null, {});
|
||||||
|
expect(result.content).toContain("received no prompt to export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning content when request is undefined", async () => {
|
||||||
|
const result = await manualExportProvider.send(undefined, {});
|
||||||
|
expect(result.content).toContain("received no prompt to export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null config gracefully", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, null);
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined config gracefully", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, undefined);
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles single-line prompt without newlines", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "single line prompt" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain("│ single line prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles prompts with only whitespace lines", async () => {
|
||||||
|
const wsPrompt = "\n\n \n\t\n";
|
||||||
|
const result = await manualExportProvider.send({ prompt: wsPrompt }, {});
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves tab characters in prompt", async () => {
|
||||||
|
const tabPrompt = "first\tsecond\tthird";
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: tabPrompt },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.content).toContain(tabPrompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves newlines within the copy box (each line as │)", async () => {
|
||||||
|
const nlPrompt = "line1\nline2\nline3";
|
||||||
|
const result = await manualExportProvider.send({ prompt: nlPrompt }, {});
|
||||||
|
// Each line should be wrapped with │ prefix
|
||||||
|
expect(result.content).toContain("│ line1");
|
||||||
|
expect(result.content).toContain("│ line2");
|
||||||
|
expect(result.content).toContain("│ line3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Provider contract compliance ---
|
||||||
|
|
||||||
|
describe("provider contract compliance", () => {
|
||||||
|
it("returns a Promise (async function)", async () => {
|
||||||
|
const result = manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
await result; // don't leave unhandled promise
|
||||||
|
});
|
||||||
|
|
||||||
|
it("content is always a string, never null or object", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: "test" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(typeof result.content).toBe("string");
|
||||||
|
expect(result.content).not.toBeNull();
|
||||||
|
expect(result.content).not.toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("content does not contain raw JSON (is text output)", async () => {
|
||||||
|
const result = await manualExportProvider.send(
|
||||||
|
{ prompt: '{"json": "in prompt"}' },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
// The JSON string should appear in the │ wrapped lines, not as a top-level JSON object
|
||||||
|
expect(result.content).not.toBe("{" + '"content":"...' + "}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always resolves (never rejects) for valid inputs", async () => {
|
||||||
|
const testCases = [
|
||||||
|
{ prompt: "hi" },
|
||||||
|
{ prompt: "" },
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
for (const tc of testCases) {
|
||||||
|
await expect(manualExportProvider.send(tc, {})).resolves.toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — same input always produces same output", async () => {
|
||||||
|
const inputs = [
|
||||||
|
{ prompt: "prompt1", input: { question: "q1" } },
|
||||||
|
{ prompt: "prompt2\nmultiline" },
|
||||||
|
{},
|
||||||
|
];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const r1 = await manualExportProvider.send(input, {});
|
||||||
|
const r2 = await manualExportProvider.send({ ...input }, {});
|
||||||
|
expect(r1.content).toBe(r2.content);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Integration: separator lines ---
|
||||||
|
|
||||||
|
describe("separator formatting", () => {
|
||||||
|
it("uses ══ as top and bottom border characters", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
// Count border lines — should have exactly 2 (top and bottom)
|
||||||
|
const match = result.content.match(/═+/g);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
expect(match.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has matching top and bottom separator blocks", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
const lines = result.content.split("\n");
|
||||||
|
expect(lines[0]).toMatch(/═+/);
|
||||||
|
expect(lines[lines.length - 1]).toMatch(/═+/);
|
||||||
|
// Top and bottom separators should be identical length
|
||||||
|
expect(lines[0].length).toBe(lines[lines.length - 1].length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes correct number of sections (header, copy box, instructions, metadata, footer)", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
expect(result.content).toContain("MANUAL EXPORT");
|
||||||
|
expect(result.content).toContain("COPY THIS PROMPT");
|
||||||
|
expect(result.content).toContain("INSTRUCTIONS");
|
||||||
|
expect(result.content).toContain("METADATA");
|
||||||
|
expect(result.content).toContain("advisory output only");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Repeatability ---
|
||||||
|
|
||||||
|
describe("repeatability", () => {
|
||||||
|
it("creates identical providers for same config each time", async () => {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const r = await manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
results.push(r.content);
|
||||||
|
}
|
||||||
|
expect(results.every((c) => c === results[0])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not share mutable state between send calls", async () => {
|
||||||
|
const r1 = await manualExportProvider.send({ prompt: "call1" }, {});
|
||||||
|
const r2 = await manualExportProvider.send({ prompt: "call2" }, {});
|
||||||
|
expect(r1.content).not.toContain("call2");
|
||||||
|
expect(r2.content).not.toContain("call1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Visual structure validation ---
|
||||||
|
|
||||||
|
describe("visual structure", () => {
|
||||||
|
it("copy box has equal left (┌) and right (┐/└/┘) corners", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
expect(result.content).toContain("┌");
|
||||||
|
expect(result.content).toContain("┐");
|
||||||
|
expect(result.content).toContain("└");
|
||||||
|
expect(result.content).toContain("┘");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy box content lines all start with │", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "line1\nline2" }, {});
|
||||||
|
const lines = result.content.split("\n");
|
||||||
|
// Find the lines between ┌ and ┘ (the copy box content)
|
||||||
|
let inBox = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes("┌─")) {
|
||||||
|
inBox = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.includes("└─")) {
|
||||||
|
inBox = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inBox && !line.includes("📋")) {
|
||||||
|
// Content lines inside box should start with │
|
||||||
|
expect(line.startsWith("│ ")).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instructions section lists exactly 5 numbered steps", async () => {
|
||||||
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
||||||
|
const instructionsMatch = result.content.match(/📝 INSTRUCTIONS:\n([\s\S]*?)(?=\n\n|$)/);
|
||||||
|
expect(instructionsMatch).not.toBeNull();
|
||||||
|
const stepLines = instructionsMatch[1].trim().split("\n").filter((l) => /^\d+\./.test(l.trim()));
|
||||||
|
expect(stepLines.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool-specific content varies by detected tool", async () => {
|
||||||
|
const resultAsk = await manualExportProvider.send(
|
||||||
|
{ prompt: "t", input: { question: "q" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const resultDebug = await manualExportProvider.send(
|
||||||
|
{ prompt: "t", input: { logs: "err" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(resultAsk.content).toContain("ask_chatgpt");
|
||||||
|
expect(resultDebug.content).toContain("debug_issue");
|
||||||
|
expect(resultAsk.content).not.toContain("debug_issue");
|
||||||
|
expect(resultDebug.content).not.toContain("ask_chatgpt");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user