Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 336 additions & 0 deletions apps/vscode-e2e/src/suite/strict-reasoning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
import * as assert from "assert"

import { RooCodeEventName, type ClineMessage } from "@roo-code/types"

import { setDefaultSuiteTimeout } from "./test-utils"
import { waitUntilCompleted } from "./utils"

/**
* E2E coverage for PR b05a-strict-reasoning-v2 ("Strict tool schemas").
*
* The PR adds the profile-scoped `openAiToolStrictMode` toggle to the OpenAI
* Compatible provider (and all providers that share the OpenAI protocol within
* the same profile). When enabled, non-MCP function tools are sent with
* `strict: true` and their JSON schemas are hardened (`additionalProperties:
* false`, every property marked required). When disabled (the default),
* tools are sent with `strict: false` and their original best-effort schemas
* are preserved. MCP tools (`mcp--*` names) must ALWAYS remain non-strict
* regardless of the toggle.
*
* These tests run the built extension bundle against the aimock server,
* intercept the outbound `/v1/chat/completions` request bodies, and assert
* on the serialized `tools` array — proving the toggle is threaded through
* configuration → provider handler → wire format end-to-end.
*/

type CapturedToolFunction = {
name?: string
strict?: boolean
parameters?: {
additionalProperties?: unknown
properties?: Record<string, unknown>
required?: string[]
}
}

type CapturedChatRequest = {
userMessageText?: string
tools: CapturedToolFunction[]
rawBody?: unknown
}

const getRequestUrl = (input: RequestInfo | URL): string =>
typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url

const messageContentText = (content: unknown): string => {
if (typeof content === "string") {
return content
}

if (Array.isArray(content)) {
return content
.map((part) => (typeof part === "object" && part !== null ? ((part as { text?: string }).text ?? "") : ""))
.join("")
}

return ""
}

/**
* Installs a fetch interceptor that records the `tools` array of every
* chat-completions request sent to the OpenAI-compatible base URL.
* Returns a restore function.
*/
const installToolCapture = (capture: CapturedChatRequest[], baseUrl: string): (() => void) => {
const originalFetch = globalThis.fetch
const targetOrigin = new URL(baseUrl).origin

globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
try {
const url = getRequestUrl(input)

if (new URL(url).origin === targetOrigin && init?.body && typeof init.body === "string") {
const body = JSON.parse(init.body) as {
tools?: Array<{ type?: string; function?: CapturedToolFunction }>
messages?: Array<{ role?: string; content?: unknown }>
}

if (Array.isArray(body.tools) && body.tools.length > 0) {
const lastUser = [...(body.messages ?? [])].reverse().find((m) => m.role === "user")

capture.push({
userMessageText: messageContentText(lastUser?.content),
tools: body.tools
.filter((t) => t?.type === "function" && t.function)
.map((t) => t.function as CapturedToolFunction),
rawBody: body,
})
}
}
} catch {
// Ignore non-JSON or unrelated traffic.
}

return originalFetch.call(globalThis, input, init as RequestInit)
} as typeof globalThis.fetch

return () => {
globalThis.fetch = originalFetch
}
}

/** Finds the captured request whose last user message contains the probe tag. */
const findProbeRequest = (requests: CapturedChatRequest[], probeTag: string) =>
requests.find((r) => r.userMessageText?.includes(probeTag))

suite("Strict tool schema mode (openAiToolStrictMode)", function () {
setDefaultSuiteTimeout(this)

let restoreFetch: (() => void) | undefined
const requests: CapturedChatRequest[] = []

let baseUrl: string

setup(function () {
// These assertions require the aimock server so the OpenAI Compatible
// provider has a deterministic endpoint to stream from. Without
// AIMOCK_URL there is no real OpenAI key configured in CI, so skip.
if (!process.env.AIMOCK_URL) {
this.skip()
}
})

suiteSetup(async () => {
const aimockUrl = process.env.AIMOCK_URL!
baseUrl = `${aimockUrl}/v1`
restoreFetch = installToolCapture(requests, baseUrl)
})
Comment on lines +123 to +127

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Skip before creating the interceptor.

setup runs after suiteSetup. When AIMOCK_URL is absent, Line 125 creates "undefined/v1" and Line 126 throws in new URL(...). The suite fails instead of skipping.

Move the AIMOCK_URL check into suiteSetup before assigning baseUrl or calling installToolCapture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/vscode-e2e/src/suite/strict-reasoning.test.ts` around lines 123 - 127,
Update the suiteSetup callback in strict-reasoning.test.ts to check whether
AIMOCK_URL is set before assigning baseUrl or invoking installToolCapture. Skip
the suite immediately when it is absent, while preserving the existing
interceptor setup when the URL is available.


suiteTeardown(async () => {
restoreFetch?.()
restoreFetch = undefined

// Restore the default OpenRouter configuration so later suites (provider
// suites run after tool suites) see the expected default profile.
const aimockUrl = process.env.AIMOCK_URL
const isRecord = process.env.AIMOCK_RECORD === "true"

await globalThis.api.setConfiguration({
apiProvider: "openrouter" as const,
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
openRouterModelId: "openai/gpt-4.1",
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
})
})

const configureOpenAiCompatible = async (strictMode: boolean | undefined) => {
await globalThis.api.setConfiguration({
apiProvider: "openai" as const,
openAiApiKey: "mock-key",
openAiBaseUrl: baseUrl,
openAiModelId: "openai/gpt-4.1",
openAiStreamingEnabled: true,
...(strictMode !== undefined && { openAiToolStrictMode: strictMode }),
})
Comment on lines +146 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the persisted strict-mode field for the default case.

When strictMode is undefined, this helper omits openAiToolStrictMode. The default-mode test can then inherit a prior saved value instead of testing the unset default.

Use the configuration reset mechanism to clear this field before the default assertion.

As per coding guidelines, “clear prior provider fields when changing persisted provider or model settings.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/vscode-e2e/src/suite/strict-reasoning.test.ts` around lines 146 - 154,
Update configureOpenAiCompatible so the strictMode === undefined path explicitly
clears the persisted openAiToolStrictMode field using the existing configuration
reset mechanism before the default assertion, while preserving the current
explicit true/false behavior.

Source: Coding guidelines

}

const runProbeTask = async (probeTag: string) => {
const api = globalThis.api

const taskId = await api.startNewTask({
configuration: { mode: "ask", autoApprovalEnabled: true },
text: `${probeTag}: what is 2+2? Reply with only the number.`,
})

await waitUntilCompleted({ api, taskId })

const captured = findProbeRequest(requests, probeTag)
assert.ok(captured, `Should have captured an outbound request containing probe tag "${probeTag}"`)
assert.ok(captured.tools.length > 0, "Captured request should include function tools")

return captured
}

test("strict mode disabled (default): non-MCP tools are sent with strict:false and unhardened schemas", async () => {
requests.length = 0
await configureOpenAiCompatible(undefined)

const captured = await runProbeTask("strict-reasoning-e2e-default")

const nonMcpTools = captured.tools.filter((t) => !t.name?.startsWith("mcp--"))
assert.ok(nonMcpTools.length > 0, "Request should contain at least one non-MCP function tool")

for (const tool of nonMcpTools) {
assert.strictEqual(
tool.strict,
false,
`Tool "${tool.name}" should be strict:false when openAiToolStrictMode is unset`,
)

// Schema hardening must NOT be applied: if properties exist, required
// must not be force-expanded to cover every property key.
if (tool.parameters?.properties) {
const allKeys = Object.keys(tool.parameters.properties)
const required = tool.parameters.required ?? []

if (allKeys.length > 0) {
assert.ok(
required.length <= allKeys.length,
`Tool "${tool.name}" required list should not exceed property count`,
)
}
}
}

// MCP tools (if any were registered) must always remain non-strict.
const mcpTools = captured.tools.filter((t) => t.name?.startsWith("mcp--"))
for (const tool of mcpTools) {
assert.strictEqual(tool.strict, false, `MCP tool "${tool.name}" must always be strict:false`)
}
})

test("strict mode explicitly disabled: identical behavior to default", async () => {
requests.length = 0
await configureOpenAiCompatible(false)

const captured = await runProbeTask("strict-reasoning-e2e-disabled")

const nonMcpTools = captured.tools.filter((t) => !t.name?.startsWith("mcp--"))
assert.ok(nonMcpTools.length > 0, "Request should contain at least one non-MCP function tool")

for (const tool of nonMcpTools) {
assert.strictEqual(
tool.strict,
false,
`Tool "${tool.name}" should be strict:false when openAiToolStrictMode is false`,
)
}
})

test("strict mode enabled: non-MCP tools are sent with strict:true and hardened schemas", async () => {
requests.length = 0
await configureOpenAiCompatible(true)

const captured = await runProbeTask("strict-reasoning-e2e-enabled")

const nonMcpTools = captured.tools.filter((t) => !t.name?.startsWith("mcp--"))
assert.ok(nonMcpTools.length > 0, "Request should contain at least one non-MCP function tool")

for (const tool of nonMcpTools) {
assert.strictEqual(
tool.strict,
true,
`Tool "${tool.name}" should be strict:true when openAiToolStrictMode is true`,
)

// Strict mode hardening: object schemas must declare
// additionalProperties:false and list every property in `required`.
if (tool.parameters?.properties) {
const allKeys = Object.keys(tool.parameters.properties)
const required = tool.parameters.required ?? []

assert.strictEqual(
tool.parameters.additionalProperties,
false,
`Tool "${tool.name}" should set additionalProperties:false under strict mode`,
)

for (const key of allKeys) {
assert.ok(
required.includes(key),
`Tool "${tool.name}" should mark property "${key}" as required under strict mode`,
)
}
}
}

// MCP tools must remain non-strict even with the toggle enabled.
const mcpTools = captured.tools.filter((t) => t.name?.startsWith("mcp--"))
for (const tool of mcpTools) {
assert.strictEqual(
tool.strict,
false,
`MCP tool "${tool.name}" must remain strict:false even when openAiToolStrictMode is true`,
)
}
})

test("strict mode toggle round-trips: enable → disable restores non-strict behavior", async () => {
// Enable strict mode and capture.
requests.length = 0
await configureOpenAiCompatible(true)
const strictOn = await runProbeTask("strict-reasoning-e2e-roundtrip-on")
assert.ok(
strictOn.tools.some((t) => !t.name?.startsWith("mcp--") && t.strict === true),
"With strict mode on, at least one non-MCP tool should be strict:true",
)

// Disable strict mode and capture again.
requests.length = 0
await configureOpenAiCompatible(false)
const strictOff = await runProbeTask("strict-reasoning-e2e-roundtrip-off")

for (const tool of strictOff.tools.filter((t) => !t.name?.startsWith("mcp--"))) {
assert.strictEqual(
tool.strict,
false,
`Tool "${tool.name}" should return to strict:false after the toggle is disabled`,
)
}
})
Comment on lines +174 to +300

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move schema serialization checks to lower-layer tests.

required.length <= allKeys.length also passes when strict-mode hardening makes every property required. The default-mode test does not prove that schemas remain unhardened.

Place exact strict, required, and additionalProperties assertions in package-local unit or integration tests with known tool schemas. Keep this E2E suite to a high-value task-completion smoke test.

As per coding guidelines, “do not place detailed protocol, parsing, storage, retry, or edge-case assertions” in E2E tests when lower-layer tests can cover them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/vscode-e2e/src/suite/strict-reasoning.test.ts` around lines 174 - 300,
Remove the detailed tool serialization and schema assertions from the
strict-mode E2E tests, including checks of strict, required, and
additionalProperties in the tests around runProbeTask. Retain only high-value
task-completion smoke coverage for the strict-mode toggle scenarios, and add or
move exact assertions to package-local unit or integration tests using known
tool schemas.

Source: Coding guidelines


test("task completes and produces assistant output regardless of strict mode setting", async () => {
requests.length = 0
await configureOpenAiCompatible(true)

const api = globalThis.api
const messages: ClineMessage[] = []

const messageHandler = ({ message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "say" && message.partial === false) {
messages.push(message)
}
Comment on lines +309 to +312

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter messages by the current task ID.

The handler receives taskId but ignores it. A late completion message from an earlier task can satisfy the assertion at Lines 328-331.

Store the new task ID and only append messages whose event taskId matches it.

As per coding guidelines, “scope assertions to the current probe or test tag” and “account for late asynchronous requests from prior tasks.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/vscode-e2e/src/suite/strict-reasoning.test.ts` around lines 309 - 312,
Update the message handler around messageHandler to retain the current task ID
when starting the probe, and only append completed “say” messages whose event
taskId matches that ID. Keep the existing partial-message filtering and
assertion flow unchanged.

Source: Coding guidelines

}

api.on(RooCodeEventName.Message, messageHandler)

try {
const taskId = await api.startNewTask({
configuration: { mode: "ask", autoApprovalEnabled: true },
text: "strict-reasoning-e2e-output: what is 2+2? Reply with only the number.",
})

await waitUntilCompleted({ api, taskId })

// The mock replies via attempt_completion; assert the task surfaced a
// completion_result (or text) message — i.e. strict-mode serialization
// did not break the response handling / display path.
assert.ok(
messages.some((m) => (m.say === "completion_result" || m.say === "text") && (m.text?.length ?? 0) > 0),
"Task should produce a visible assistant completion message under strict mode",
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
})
})
Loading
Loading