feat: implement fuzzy search and replace functionality with Levenshtein distance
- Added `applySearchReplace` function to handle search and replace operations with fuzzy matching capabilities. - Introduced tests for various scenarios including fuzzy matching with typos, exact matches, and handling whitespace differences. - Created a parser for search/replace blocks to facilitate the new functionality. - Updated prompts for search-replace operations to clarify usage and examples. - Added utility functions for text normalization and language detection based on file extensions. - Implemented a minimal stdio MCP server for local testing with tools for adding numbers and printing environment variables.
This commit is contained in:
50
testing/README.md
Normal file
50
testing/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
### Fake stdio MCP server
|
||||
|
||||
This directory contains a minimal stdio MCP server for local testing.
|
||||
|
||||
- **Tools**:
|
||||
- **calculator_add**: adds two numbers. Inputs: `a` (number), `b` (number).
|
||||
- **print_envs**: returns all environment variables visible to the server as pretty JSON.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Node 20+** (same as the repo engines)
|
||||
- Uses the repo dependency `@modelcontextprotocol/sdk` and `zod`
|
||||
|
||||
### Launch
|
||||
|
||||
- **Via Node**:
|
||||
|
||||
```bash
|
||||
node testing/fake-stdio-mcp-server.mjs
|
||||
```
|
||||
|
||||
- **Via script** (adds a stable entrypoint path):
|
||||
|
||||
```bash
|
||||
testing/run-fake-stdio-mcp-server.sh
|
||||
```
|
||||
|
||||
### Passing environment variables
|
||||
|
||||
Environment variables provided when launching (either from your shell or by the app) will be visible to the `print_envs` tool.
|
||||
|
||||
```bash
|
||||
export FOO=bar
|
||||
export SECRET_TOKEN=example
|
||||
testing/run-fake-stdio-mcp-server.sh
|
||||
```
|
||||
|
||||
### Integrating with Dyad (stdio MCP)
|
||||
|
||||
When adding a stdio MCP server in the app, use:
|
||||
|
||||
- **Command**: `testing/run-fake-stdio-mcp-server.sh` (absolute path recommended)
|
||||
- **Transport**: `stdio`
|
||||
- **Args**: leave empty (not required)
|
||||
- **Env**: optional key/values (e.g., `FOO=bar`)
|
||||
|
||||
Once connected, you should see the two tools listed:
|
||||
|
||||
- `calculator_add`
|
||||
- `print_envs`
|
||||
@@ -7,6 +7,7 @@ A simple server that mimics the OpenAI streaming chat completions API for testin
|
||||
- Implements a basic version of the OpenAI chat completions API
|
||||
- Supports both streaming and non-streaming responses
|
||||
- Always responds with "hello world" message
|
||||
- Simulates a 429 rate limit error when the last message is "[429]"
|
||||
- Configurable through environment variables
|
||||
|
||||
## Installation
|
||||
@@ -82,6 +83,29 @@ For non-streaming requests, you'll get a standard JSON response:
|
||||
|
||||
For streaming requests, you'll receive a series of server-sent events (SSE), each containing a chunk of the response.
|
||||
|
||||
### Simulating Rate Limit Errors
|
||||
|
||||
To test how your application handles rate limiting, send a message with content exactly equal to `[429]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [{ "role": "user", "content": "[429]" }],
|
||||
"model": "any-model"
|
||||
}
|
||||
```
|
||||
|
||||
This will return a 429 status code with the following response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Too many requests. Please try again later.",
|
||||
"type": "rate_limit_error",
|
||||
"param": null,
|
||||
"code": "rate_limit_exceeded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CANNED_MESSAGE, createStreamChunk } from ".";
|
||||
let globalCounter = 0;
|
||||
|
||||
export const createChatCompletionHandler =
|
||||
(prefix: string) => (req: Request, res: Response) => {
|
||||
(prefix: string) => async (req: Request, res: Response) => {
|
||||
const { stream = false, messages = [] } = req.body;
|
||||
console.log("* Received messages", messages);
|
||||
|
||||
@@ -32,6 +32,14 @@ DYAD_ATTACHMENT_0
|
||||
messageContent += "\n\n" + generateDump(req);
|
||||
}
|
||||
|
||||
if (
|
||||
lastMessage &&
|
||||
typeof lastMessage.content === "string" &&
|
||||
lastMessage.content.includes("[sleep=medium]")
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||
}
|
||||
|
||||
// TS auto-fix prefixes
|
||||
if (
|
||||
lastMessage &&
|
||||
@@ -109,6 +117,42 @@ export default Index;
|
||||
</dyad-write>
|
||||
`;
|
||||
}
|
||||
if (
|
||||
lastMessage &&
|
||||
typeof lastMessage.content === "string" &&
|
||||
lastMessage.content.startsWith(
|
||||
"There was an issue with the following `dyad-search-replace` tags.",
|
||||
)
|
||||
) {
|
||||
if (lastMessage.content.includes("Make sure you use `dyad-read`")) {
|
||||
// Fix errors in create-ts-errors.md and introduce a new error
|
||||
messageContent =
|
||||
`
|
||||
<dyad-read path="src/pages/Index.tsx"></dyad-read>
|
||||
|
||||
<dyad-search-replace path="src/pages/Index.tsx">
|
||||
<<<<<<< SEARCH
|
||||
// STILL Intentionally DO NOT MATCH ANYTHING TO TRIGGER FALLBACK
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
|
||||
=======
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to the UPDATED App</h1>
|
||||
>>>>>>> REPLACE
|
||||
</dyad-search-replace>
|
||||
` +
|
||||
"\n\n" +
|
||||
generateDump(req);
|
||||
} else {
|
||||
// Fix errors in create-ts-errors.md and introduce a new error
|
||||
messageContent =
|
||||
`
|
||||
<dyad-write path="src/pages/Index.tsx" description="Rewrite file.">
|
||||
// FILE IS REPLACED WITH FALLBACK WRITE.
|
||||
</dyad-write>` +
|
||||
"\n\n" +
|
||||
generateDump(req);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("LASTMESSAGE", lastMessage);
|
||||
// Check if the last message is "[dump]" to write messages to file and return path
|
||||
if (
|
||||
@@ -123,6 +167,27 @@ export default Index;
|
||||
messageContent = generateDump(req);
|
||||
}
|
||||
|
||||
if (
|
||||
lastMessage &&
|
||||
typeof lastMessage.content === "string" &&
|
||||
lastMessage.content.startsWith("/security-review")
|
||||
) {
|
||||
messageContent = fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"e2e-tests",
|
||||
"fixtures",
|
||||
"security-review",
|
||||
"findings.md",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
messageContent += "\n\n" + generateDump(req);
|
||||
}
|
||||
|
||||
if (lastMessage && lastMessage.content === "[increment]") {
|
||||
globalCounter++;
|
||||
messageContent = `counter=${globalCounter}`;
|
||||
@@ -135,7 +200,8 @@ export default Index;
|
||||
typeof lastMessage.content === "string" &&
|
||||
lastMessage.content.startsWith("tc=")
|
||||
) {
|
||||
const testCaseName = lastMessage.content.slice(3); // Remove "tc=" prefix
|
||||
const testCaseName = lastMessage.content.slice(3).split("[")[0].trim(); // Remove "tc=" prefix
|
||||
console.error(`* Loading test case: ${testCaseName}`);
|
||||
const testFilePath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
@@ -152,7 +218,7 @@ export default Index;
|
||||
messageContent = fs.readFileSync(testFilePath, "utf-8");
|
||||
console.log(`* Loaded test case: ${testCaseName}`);
|
||||
} else {
|
||||
console.log(`* Test case file not found: ${testFilePath}`);
|
||||
console.error(`* Test case file not found: ${testFilePath}`);
|
||||
messageContent = `Error: Test case file not found: ${testCaseName}.md`;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -170,9 +236,46 @@ export default Index;
|
||||
messageContent = `[[STRING_IS_FINISHED]]";</dyad-write>\nFinished writing file.`;
|
||||
messageContent += "\n\n" + generateDump(req);
|
||||
}
|
||||
const isToolCall = !!(
|
||||
lastMessage &&
|
||||
lastMessage.content &&
|
||||
lastMessage.content.includes("[call_tool=calculator_add]")
|
||||
);
|
||||
let message = {
|
||||
role: "assistant",
|
||||
content: messageContent,
|
||||
} as any;
|
||||
|
||||
// Non-streaming response
|
||||
if (!stream) {
|
||||
if (isToolCall) {
|
||||
const toolCallId = `call_${Date.now()}`;
|
||||
return res.json({
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "fake-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: "calculator_add",
|
||||
arguments: JSON.stringify({ a: 1, b: 2 }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
@@ -181,10 +284,7 @@ export default Index;
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: messageContent,
|
||||
},
|
||||
message,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
@@ -196,13 +296,77 @@ export default Index;
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
|
||||
// Tool call streaming (OpenAI-style)
|
||||
if (isToolCall) {
|
||||
const now = Date.now();
|
||||
const mkChunk = (delta: any, finish: null | string = null) => {
|
||||
const chunk = {
|
||||
id: `chatcmpl-${now}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(now / 1000),
|
||||
model: "fake-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta,
|
||||
finish_reason: finish,
|
||||
},
|
||||
],
|
||||
};
|
||||
return `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
};
|
||||
|
||||
// 1) Send role
|
||||
res.write(mkChunk({ role: "assistant" }));
|
||||
|
||||
// 2) Send tool_calls init with id + name + empty args
|
||||
const toolCallId = `call_${now}`;
|
||||
res.write(
|
||||
mkChunk({
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: "testing-mcp-server__calculator_add",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// 3) Stream arguments gradually
|
||||
const args = JSON.stringify({ a: 1, b: 2 });
|
||||
let i = 0;
|
||||
const argBatchSize = 6;
|
||||
const argInterval = setInterval(() => {
|
||||
if (i < args.length) {
|
||||
const part = args.slice(i, i + argBatchSize);
|
||||
i += argBatchSize;
|
||||
res.write(
|
||||
mkChunk({
|
||||
tool_calls: [{ index: 0, function: { arguments: part } }],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// 4) Finalize with finish_reason tool_calls and [DONE]
|
||||
res.write(mkChunk({}, "tool_calls"));
|
||||
res.write("data: [DONE]\n\n");
|
||||
clearInterval(argInterval);
|
||||
res.end();
|
||||
}
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the message into characters to simulate streaming
|
||||
const message = messageContent;
|
||||
const messageChars = message.split("");
|
||||
const messageChars = messageContent.split("");
|
||||
|
||||
// Stream each character with a delay
|
||||
let index = 0;
|
||||
const batchSize = 8;
|
||||
const batchSize = 32;
|
||||
|
||||
// Send role first
|
||||
res.write(createStreamChunk("", "assistant"));
|
||||
|
||||
@@ -137,13 +137,20 @@ app.get("/lmstudio/api/v0/models", (req, res) => {
|
||||
res.json(lmStudioModels);
|
||||
});
|
||||
|
||||
["lmstudio", "gateway", "engine", "ollama"].forEach((provider) => {
|
||||
["lmstudio", "gateway", "engine", "ollama", "azure"].forEach((provider) => {
|
||||
app.post(
|
||||
`/${provider}/v1/chat/completions`,
|
||||
createChatCompletionHandler(provider),
|
||||
);
|
||||
});
|
||||
|
||||
// Azure-specific endpoints (Azure client uses different URL patterns)
|
||||
app.post("/azure/chat/completions", createChatCompletionHandler("azure"));
|
||||
app.post(
|
||||
"/azure/openai/deployments/:deploymentId/chat/completions",
|
||||
createChatCompletionHandler("azure"),
|
||||
);
|
||||
|
||||
// Default test provider handler:
|
||||
app.post("/v1/chat/completions", createChatCompletionHandler("."));
|
||||
|
||||
|
||||
44
testing/fake-stdio-mcp-server.mjs
Normal file
44
testing/fake-stdio-mcp-server.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "fake-stdio-mcp",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"calculator_add",
|
||||
{
|
||||
title: "Calculator Add",
|
||||
description: "Add two numbers and return the sum",
|
||||
inputSchema: { a: z.number(), b: z.number() },
|
||||
},
|
||||
async ({ a, b }) => {
|
||||
const sum = a + b;
|
||||
return {
|
||||
content: [{ type: "text", text: String(sum) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"print_envs",
|
||||
{
|
||||
title: "Print Envs",
|
||||
description: "Print the environment variables received by the server",
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const envObject = Object.fromEntries(
|
||||
Object.entries(process.env).map(([key, value]) => [key, value ?? ""]),
|
||||
);
|
||||
const pretty = JSON.stringify(envObject, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: pretty }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
12
testing/run-fake-stdio-mcp-server.sh
Executable file
12
testing/run-fake-stdio-mcp-server.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Launch the fake stdio MCP server with Node.
|
||||
# Usage: testing/run-fake-stdio-mcp-server.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NODE_BIN="node"
|
||||
|
||||
exec "$NODE_BIN" "$SCRIPT_DIR/fake-stdio-mcp-server.mjs"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user