diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4f10b88..234d810 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. --- diff --git a/README.md b/README.md index 3141ec6..b05eb40 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/providers/factory.js b/src/providers/factory.js index f666f26..cb6bfe4 100644 --- a/src/providers/factory.js +++ b/src/providers/factory.js @@ -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}".`); diff --git a/src/providers/manual-export.js b/src/providers/manual-export.js new file mode 100644 index 0000000..3ef56a5 --- /dev/null +++ b/src/providers/manual-export.js @@ -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) }; + }, +}; diff --git a/src/providers/openai.js b/src/providers/openai.js index 6a5d3e1..45a9008 100644 --- a/src/providers/openai.js +++ b/src/providers/openai.js @@ -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 }>} */ diff --git a/src/tools/architecture-review.js b/src/tools/architecture-review.js index 960be6c..6c57aa1 100644 --- a/src/tools/architecture-review.js +++ b/src/tools/architecture-review.js @@ -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: [] }; } diff --git a/src/tools/ask-chatgpt.js b/src/tools/ask-chatgpt.js index c05d0ab..439c72c 100644 --- a/src/tools/ask-chatgpt.js +++ b/src/tools/ask-chatgpt.js @@ -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: [] }; diff --git a/src/tools/debug-issue.js b/src/tools/debug-issue.js index fda9ab4..4795689 100644 --- a/src/tools/debug-issue.js +++ b/src/tools/debug-issue.js @@ -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: [] }; } diff --git a/src/tools/review-code.js b/src/tools/review-code.js index 69f4185..ccd3101 100644 --- a/src/tools/review-code.js +++ b/src/tools/review-code.js @@ -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: [] }; } diff --git a/src/tools/review-plan.js b/src/tools/review-plan.js index 3776424..fa2347d 100644 --- a/src/tools/review-plan.js +++ b/src/tools/review-plan.js @@ -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: [] }; diff --git a/test/providers/factory.test.js b/test/providers/factory.test.js index ed7b79f..8be2312 100644 --- a/test/providers/factory.test.js +++ b/test/providers/factory.test.js @@ -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); + }); +}); diff --git a/test/providers/manual-export.test.js b/test/providers/manual-export.test.js new file mode 100644 index 0000000..f149875 --- /dev/null +++ b/test/providers/manual-export.test.js @@ -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 = ''; + const result = await manualExportProvider.send( + { prompt: htmlPrompt, input: {} }, + {}, + ); + expect(result.content).toContain('