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:
2026-06-15 10:35:45 +01:00
parent 732d6edd38
commit f38b988a8f
12 changed files with 736 additions and 17 deletions
+10 -3
View File
@@ -439,10 +439,17 @@ src/openai/responses.js — sendOpenAIResponse(client, params) API call wra
Configured via `CHATGPT_MCP_PROVIDER` env var (defaults to `"openai"`):
```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
@@ -453,7 +460,7 @@ OPENAI_TEMPERATURE=0.2
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.
---
+10 -1
View File
@@ -61,7 +61,16 @@ OpenAI Provider → OpenAI Responses API
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.
+6 -2
View File
@@ -2,15 +2,17 @@
// Returns a chat provider based on config value CHATGPT_MCP_PROVIDER.
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.
* Defaults to OpenAI if no provider is specified or value is invalid.
* @param {object} config - Loaded config object (must contain openaiApiKey).
* @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) {
const providerName = config?.chatgptMcpProvider || "openai";
@@ -24,6 +26,8 @@ export function createChatProvider(config) {
switch (providerName) {
case "openai":
return openaiProvider;
case "manual":
return manualExportProvider;
default:
// Should not reach here because of the set check above.
throw new Error(`Unknown provider "${providerName}".`);
+104
View File
@@ -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) };
},
};
+1 -1
View File
@@ -10,7 +10,7 @@ import { sendOpenAIResponse } from "../openai/responses.js";
*/
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().
* @returns {Promise<{ content: string }>}
*/
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildArchitectureReviewPrompt } from "../prompts/architecture-review.js
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* 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
* Injected external dependencies.
* @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: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
}
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildAskChatGptPrompt } from "../prompts/ask-chatgpt.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* 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
* Injected external dependencies.
* @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: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildDebugIssuePrompt } from "../prompts/debug-issue.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* 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
* Injected external dependencies.
* @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: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
}
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildReviewCodePrompt } from "../prompts/review-code.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* 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
* Injected external dependencies.
* @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: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
}
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildReviewPlanPrompt } from "../prompts/review-plan.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* 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
* Injected external dependencies.
* @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: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
+64
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { createChatProvider, openaiProvider } from "../../src/providers/factory.js";
import { manualExportProvider } from "../../src/providers/manual-export.js";
function mockConfig(provider) {
const cfg = { chatgptMcpProvider: provider };
@@ -195,3 +196,66 @@ describe("repeatability", () => {
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);
});
});
+506
View File
@@ -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");
});
});