diff --git a/apps/vscode/src/test/e2e/diff.test.ts b/apps/vscode/src/test/e2e/diff.test.ts
deleted file mode 100644
index f0a374008d..0000000000
--- a/apps/vscode/src/test/e2e/diff.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { expect } from "@playwright/test"
-import { E2E_WORKSPACE_TYPES, e2e } from "./utils/helpers"
-
-e2e.describe("Diff Editor", () => {
- E2E_WORKSPACE_TYPES.forEach(({ title, workspaceType }) => {
- e2e.extend({
- workspaceType,
- })(title, async ({ helper, sidebar }) => {
- await helper.signin(sidebar)
-
- const inputbox = sidebar.getByTestId("chat-input")
- await expect(inputbox).toBeVisible()
-
- await inputbox.fill("[diff.test.ts] Hello, Cline!")
- await expect(inputbox).toHaveValue("[diff.test.ts] Hello, Cline!")
- await sidebar.getByTestId("send-button").click()
- await expect(inputbox).toHaveValue("")
-
- // Wait for the (mock) agent turn to finish before navigating away —
- // the task is persisted to SDK session history when the turn completes,
- // and the mock server delays this response by 500ms.
- await expect(sidebar.getByText("mock Cline API response")).toBeVisible()
-
- // Back to home page with history. The turn ends in "awaiting_followup"
- // (the mock response has no attempt_completion), so the footer shows no
- // "Start New Task" button — use the header "New Task" button instead,
- // same as chat.test.ts.
- await sidebar.getByRole("button", { name: "New Task", exact: true }).first().click()
- await expect(sidebar.getByText("Recent")).toBeVisible()
- await expect(sidebar.getByText("Hello, Cline!")).toBeVisible() // History with the previous sent message
-
- // Submit a file edit request
- await sidebar.getByTestId("chat-input").click()
- await sidebar.getByTestId("chat-input").fill("edit_request")
- await sidebar.getByTestId("send-button").click({ delay: 50 })
-
- // Wait for the sidebar to load the file edit request
- await sidebar.waitForSelector('span:has-text("Cline wants to edit this file:")')
-
- // The SDK-backed path renders a pending edit approval before the user saves it.
- await expect(sidebar.getByText(/\/test\.ts/)).toBeVisible()
- await expect(sidebar.getByRole("button", { name: "Save" })).toBeVisible()
- await expect(sidebar.getByRole("button", { name: "Reject" })).toBeVisible()
- })
- })
-})
diff --git a/apps/vscode/src/test/e2e/file-edit.test.ts b/apps/vscode/src/test/e2e/file-edit.test.ts
new file mode 100644
index 0000000000..e528addd58
--- /dev/null
+++ b/apps/vscode/src/test/e2e/file-edit.test.ts
@@ -0,0 +1,60 @@
+import { readFileSync, writeFileSync } from "node:fs"
+import * as path from "node:path"
+import { expect } from "@playwright/test"
+import { E2E_WORKSPACE_TYPES, e2e } from "./utils/helpers"
+
+// File edits are performed by the SDK's `editor` tool executor, which writes
+// the file directly (Node fs) after the user approves the tool call — it does
+// not stream the edit through DiffViewProvider, so no diff editor tab (e.g.
+// "test.ts: Original ↔ Cline's Changes") opens. This test asserts the
+// approval flow: approval ask row → Save → file modified on disk →
+// turn-ending completion text.
+e2e.describe("File Edit Approval", () => {
+ E2E_WORKSPACE_TYPES.forEach(({ title, workspaceType }) => {
+ e2e.extend({
+ workspaceType,
+ })(title, async ({ helper, sidebar, workspaceDir }) => {
+ // The mock editor tool call targets "test.ts" relative to the session
+ // cwd, which is the first workspace folder in both single-root and
+ // multi-root workspaces (fixtures/workspace). The fixture file is
+ // checked into git, so restore it after the (real) edit.
+ const editedFilePath = path.join(workspaceDir, "test.ts")
+ let originalFileContent: string | undefined
+
+ try {
+ originalFileContent = readFileSync(editedFilePath, "utf-8")
+
+ await helper.signin(sidebar)
+
+ // Submit a file edit request. The mock server responds with a
+ // structured `editor` tool call (path: test.ts, old/new text).
+ const inputbox = sidebar.getByTestId("chat-input")
+ await expect(inputbox).toBeVisible()
+ await inputbox.fill("edit_request")
+ await sidebar.getByTestId("send-button").click({ delay: 50 })
+
+ // The edit tool requires approval (edit tools are never auto-approved
+ // by default) — the ask row appears with the file path and diff.
+ await sidebar.waitForSelector('span:has-text("Cline wants to edit this file:")')
+ await expect(sidebar.getByText("test.ts").first()).toBeVisible()
+ await expect(sidebar.getByRole("button", { name: "Reject" })).toBeVisible()
+
+ // Approve the edit ("Save" is the primary button for file-edit asks).
+ await sidebar.getByRole("button", { name: "Save", exact: true }).click({ delay: 50 })
+
+ // The SDK executes the editor tool and sends the tool result back to
+ // the (mock) model, which replies with turn-ending completion text.
+ await expect(sidebar.getByText("I successfully replaced")).toBeVisible({ timeout: 30_000 })
+
+ // The edit was actually applied to the file on disk.
+ expect(readFileSync(editedFilePath, "utf-8")).toContain('export const name = "cline"')
+ } finally {
+ // Skip the restore when the initial read failed — there is
+ // nothing to restore and the read error is the real failure.
+ if (originalFileContent !== undefined) {
+ writeFileSync(editedFilePath, originalFileContent, "utf-8")
+ }
+ }
+ })
+ })
+})
diff --git a/apps/vscode/src/test/e2e/fixtures/server/api.ts b/apps/vscode/src/test/e2e/fixtures/server/api.ts
index 04d8b38371..e84bfe92a8 100644
--- a/apps/vscode/src/test/e2e/fixtures/server/api.ts
+++ b/apps/vscode/src/test/e2e/fixtures/server/api.ts
@@ -29,58 +29,41 @@ export const E2E_REGISTERED_MOCK_ENDPOINTS = {
},
}
-const replace_in_file = `I successfully replaced "john" with "cline" in the test.ts file. The change has been completed and the file now contains:
+/**
+ * Structured `editor` tool call streamed in response to the `edit_request`
+ * prompt. The mock server streams it as OpenAI-format
+ * `choices[].delta.tool_calls[]` deltas followed by
+ * `finish_reason: "tool_calls"`, which is the only tool-call syntax the SDK
+ * runtime executes.
+ *
+ * The `path` is workspace-relative; the SDK editor executor resolves relative
+ * paths against the session cwd, which is the first workspace folder in both
+ * the single-root and multi-root e2e workspaces (`fixtures/workspace`).
+ */
+export const E2E_MOCK_EDITOR_TOOL_CALL = {
+ id: "call_e2e_edit_1",
+ name: "editor",
+ arguments: {
+ path: "test.ts",
+ old_text: 'export const name = "john"',
+ new_text: 'export const name = "cline"',
+ },
+}
+
+const edit_request_complete = `I successfully replaced "john" with "cline" in the test.ts file. The change has been completed and the file now contains:
\`\`\`typescript
export const name = "cline"
\`\`\`
-The TypeScript errors shown in the output are unrelated to this change - they appear to be existing issues in the broader codebase related to missing type definitions and dependencies. The specific task of updating the name in test.ts has been completed successfully.
-
-
-
-I have successfully replaced the name "john" with "cline" in the test.ts file. The file now exports:
-
-\`\`\`typescript
-export const name = "cline"
-\`\`\`
-
-The change has been applied and saved to the file.
-
-`
-
-const edit_request = `
-The user wants me to replace the name "john" with "cline" in the test.ts file. I can see the file content provided:
-
-\`\`\`typescript
-export const name = "john"
-\`\`\`
-
-I need to change "john" to "cline". This is a simple targeted edit, so I should use the replace_in_file tool rather than write_to_file since I'm only changing one small part of the file.
-
-I need to:
-1. Use replace_in_file to change "john" to "cline" in the test.ts file
-2. The SEARCH block should match the exact content: \`export const name = "john"\`
-3. The REPLACE block should be: \`export const name = "cline"\`
-
-
-I'll replace "john" with "cline" in the test.ts file.
-
-
-test.ts
-
-------- SEARCH
-export const name = "john"
-=======
-export const name = "cline"
-+++++++ REPLACE
-
-`
+The change has been applied and saved to the file.`
export const E2E_MOCK_API_RESPONSES = {
DEFAULT: "Hello! I'm a mock Cline API response.",
- REPLACE_REQUEST: replace_in_file,
- EDIT_REQUEST: edit_request,
+ /** Assistant text streamed before the structured editor tool call. */
+ EDIT_REQUEST_LEAD_IN: `I'll replace "john" with "cline" in the test.ts file.`,
+ /** Turn-ending text streamed after the SDK reports the editor tool result. */
+ EDIT_REQUEST_COMPLETE: edit_request_complete,
}
export const E2E_MOCK_CLINE_RECOMMENDED_MODELS = {
diff --git a/apps/vscode/src/test/e2e/fixtures/server/index.ts b/apps/vscode/src/test/e2e/fixtures/server/index.ts
index 86537cf9de..b3f5abd42a 100644
--- a/apps/vscode/src/test/e2e/fixtures/server/index.ts
+++ b/apps/vscode/src/test/e2e/fixtures/server/index.ts
@@ -6,6 +6,7 @@ import {
E2E_MOCK_API_RESPONSES,
E2E_MOCK_CLINE_MODELS,
E2E_MOCK_CLINE_RECOMMENDED_MODELS,
+ E2E_MOCK_EDITOR_TOOL_CALL,
E2E_REGISTERED_MOCK_ENDPOINTS,
} from "./api"
import { ClineDataMock } from "./data"
@@ -472,26 +473,35 @@ export class ClineApiServerMock {
const body = await readBody()
const parsed = JSON.parse(body)
- const { _messages, model = "claude-3-5-sonnet-20241022", stream = true } = parsed
- let responseText = E2E_MOCK_API_RESPONSES.DEFAULT
- const isEditRequest = body.includes("edit_request")
- log("Chat completion mock selection:", {
- isEditRequest,
- isReplaceResult: body.includes("[replace_in_file for 'test.ts'] Result:"),
- })
- if (body.includes("[replace_in_file for 'test.ts'] Result:")) {
- responseText = E2E_MOCK_API_RESPONSES.REPLACE_REQUEST
- }
- if (isEditRequest) {
- responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST
- }
- if (body.includes("[diff.test.ts] Hello, Cline!")) {
- // The playwright test in diff.test.ts needs the "API Request..." text
- // to be on the screen long enough to detect it. This worked at 100ms
- // too, but setting to 500ms to cover slower CI boxes.
- await new Promise((resolve) => setTimeout(resolve, 500))
- }
+ const { messages, model = "claude-3-5-sonnet-20241022", stream = true } = parsed
+ // The SDK runtime executes structured tool calls and then sends a
+ // follow-up /chat/completions request containing the tool result as
+ // a `role: "tool"` message. Detect that follow-up first — the
+ // original "edit_request" user prompt is still present in the
+ // conversation history of the follow-up request, so order matters.
+ // Scoped to edit_request conversations so tool results from other
+ // (future) scenarios don't mis-route to EDIT_REQUEST_COMPLETE.
+ const hasToolResult =
+ body.includes("edit_request") &&
+ Array.isArray(messages) &&
+ messages.some((m: { role?: string }) => m?.role === "tool")
+
+ let responseText = E2E_MOCK_API_RESPONSES.DEFAULT
+ let includeEditorToolCall = false
+ log("Chat completion mock selection:", {
+ isEditRequest: body.includes("edit_request"),
+ hasToolResult,
+ })
+ if (hasToolResult) {
+ responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST_COMPLETE
+ } else if (body.includes("edit_request")) {
+ // Stream lead-in text followed by a structured `editor` tool
+ // call (OpenAI tool_calls deltas) — the only tool-call syntax
+ // the SDK runtime executes.
+ responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST_LEAD_IN
+ includeEditorToolCall = true
+ }
const generationId = `gen_${++controller.generationCounter}_${Date.now()}`
if (stream) {
@@ -503,68 +513,44 @@ export class ClineApiServerMock {
const randomUUID = uuidv4()
- if (isEditRequest) {
- const toolCallId = `call_${randomUUID}`
- const toolCallChunk = {
- id: generationId,
- object: "chat.completion.chunk",
- created: Math.floor(Date.now() / 1000),
- model,
- choices: [
- {
- index: 0,
- delta: {
- tool_calls: [
- {
- index: 0,
- id: toolCallId,
- type: "function",
- function: {
- name: "editor",
- arguments: JSON.stringify({
- path: "test.ts",
- old_text: 'export const name = "john"',
- new_text: 'export const name = "cline"',
- }),
- },
- },
- ],
- },
- finish_reason: null,
- },
- ],
- }
- const finalChunk = {
- id: generationId,
- object: "chat.completion.chunk",
- created: Math.floor(Date.now() / 1000),
- model,
- choices: [
- {
- index: 0,
- delta: {},
- finish_reason: "tool_calls",
- },
- ],
- usage: {
- prompt_tokens: 140,
- completion_tokens: responseText.length,
- total_tokens: 140 + responseText.length,
- cost: (140 + responseText.length) * 0.00015,
- },
- }
- res.write(`data: ${JSON.stringify(toolCallChunk)}\n\n`)
- res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
- res.write("data: [DONE]\n\n")
- res.end()
- return
- }
-
responseText += `\n\nGenerated UUID: ${randomUUID}`
const chunks = responseText.split(" ")
let chunkIndex = 0
+ // OpenAI-format streamed tool call deltas, matching what the
+ // AI SDK's openai-compatible client expects: the first delta
+ // for a tool_calls index must carry `id` + `function.name`;
+ // `function.arguments` accumulates as string fragments. Split
+ // the arguments JSON to exercise fragment reassembly.
+ const argumentsJson = JSON.stringify(E2E_MOCK_EDITOR_TOOL_CALL.arguments)
+ const argsSplitAt = Math.floor(argumentsJson.length / 2)
+ const toolCallDeltas = includeEditorToolCall
+ ? [
+ [
+ {
+ index: 0,
+ id: E2E_MOCK_EDITOR_TOOL_CALL.id,
+ type: "function",
+ function: { name: E2E_MOCK_EDITOR_TOOL_CALL.name, arguments: "" },
+ },
+ ],
+ [
+ {
+ index: 0,
+ function: { arguments: argumentsJson.slice(0, argsSplitAt) },
+ },
+ ],
+ [
+ {
+ index: 0,
+ function: { arguments: argumentsJson.slice(argsSplitAt) },
+ },
+ ],
+ ]
+ : []
+ let toolCallDeltaIndex = 0
+
const sendChunk = () => {
if (chunkIndex < chunks.length) {
const chunk = {
@@ -585,6 +571,25 @@ export class ClineApiServerMock {
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
chunkIndex++
setTimeout(sendChunk, 10)
+ } else if (toolCallDeltaIndex < toolCallDeltas.length) {
+ const chunk = {
+ id: generationId,
+ object: "chat.completion.chunk",
+ created: Math.floor(Date.now() / 1000),
+ model,
+ choices: [
+ {
+ index: 0,
+ delta: {
+ tool_calls: toolCallDeltas[toolCallDeltaIndex],
+ },
+ finish_reason: null,
+ },
+ ],
+ }
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`)
+ toolCallDeltaIndex++
+ setTimeout(sendChunk, 10)
} else {
const finalChunk = {
id: generationId,
@@ -595,7 +600,7 @@ export class ClineApiServerMock {
{
index: 0,
delta: {},
- finish_reason: "stop",
+ finish_reason: includeEditorToolCall ? "tool_calls" : "stop",
},
],
usage: {