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
12 changes: 6 additions & 6 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ go—and, importantly, where they don't.
- **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI
model), it is stored locally on your device and never sent to us or any third
party, except the provider you have chosen.
- **Telemetry (Usage Data)**: We collect feature usage and error data to help
us improve Zoo Code. This telemetry is powered by PostHog and includes your
VS Code machine ID, feature usage patterns, and exception reports. The VS Code
- **Telemetry (Usage Data)**: We collect feature usage and error data to help us
improve Zoo Code. This telemetry is powered by PostHog and includes your VS
Code machine ID, feature usage patterns, and exception reports. The VS Code
machine ID is a persistent identifier and may be considered personal data in
some jurisdictions; we use it only for product analytics and error grouping.
We retain telemetry only as long as needed for product analytics and debugging.
Telemetry does **not** collect your code or AI prompts, and you can opt out at
any time through the settings.
We retain telemetry only as long as needed for product analytics and
debugging. This PostHog-based telemetry does **not** collect your code or AI
prompts, and you can opt out at any time through the settings.
- **Marketplace Requests**: When you browse or search the Marketplace for Model
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
call to Zoo Code's backend servers to retrieve listing information. These
Expand Down
60 changes: 51 additions & 9 deletions apps/vscode-e2e/src/bedrock-mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface BedrockMockServer {
url: string
/** Headers from the most recent converse-stream request (populated after first call). */
lastRequestHeaders: http2.IncomingHttpHeaders | undefined
/** Parsed JSON bodies of every converse-stream request received so far, oldest first. */
requestBodies: unknown[]
close(): Promise<void>
}

Expand Down Expand Up @@ -59,7 +61,7 @@ function encodeFrame(eventType: string, payload: object): Buffer {
return frame
}

function buildToolCallFrames(toolName: string, toolUseId: string, argsJson: string): Buffer[] {
export function buildToolCallFrames(toolName: string, toolUseId: string, argsJson: string): Buffer[] {
const frames: Buffer[] = []
frames.push(encodeFrame("messageStart", { role: "assistant" }))
frames.push(
Expand Down Expand Up @@ -88,7 +90,40 @@ function buildToolCallFrames(toolName: string, toolUseId: string, argsJson: stri
return frames
}

export async function startBedrockMockServer(): Promise<BedrockMockServer> {
// A response with no content blocks at all -- no text, no tool_use. Exercises the
// "no assistant messages" retry path (Task#recursivelyMakeClineRequests), which is
// otherwise unreachable from a real model (a real model asked to return nothing
// still typically returns some text) and untestable at the unit level without
// deeply mocking the streaming loop.
export function buildEmptyResponseFrames(): Buffer[] {
const frames: Buffer[] = []
frames.push(encodeFrame("messageStart", { role: "assistant" }))
frames.push(encodeFrame("messageStop", { stopReason: "end_turn" }))
frames.push(
encodeFrame("metadata", {
metrics: { latencyMs: 1 },
usage: { inputTokens: 100, outputTokens: 0, totalTokens: 100, serverToolUsage: {} },
}),
)
return frames
}

export interface BedrockMockServerOptions {
/**
* Frame sequences to serve, one per converse-stream request, in order. The last
* entry repeats for any request beyond the queue's length. Defaults to always
* returning the attempt_completion("4") tool call (the pre-existing behavior).
*/
responses?: Buffer[][]
}

export async function startBedrockMockServer(options: BedrockMockServerOptions = {}): Promise<BedrockMockServer> {
const responses = options.responses ?? [
buildToolCallFrames("attempt_completion", "tooluse_bedrock_mock_001", JSON.stringify({ result: "4" })),
]
let requestCount = 0
const requestBodies: unknown[] = []

// HTTP/2 cleartext (h2c) — matches what @aws-sdk/client-bedrock-runtime uses by default.
const server = http2.createServer()
let lastRequestHeaders: http2.IncomingHttpHeaders | undefined
Expand All @@ -111,18 +146,22 @@ export async function startBedrockMockServer(): Promise<BedrockMockServer> {

lastRequestHeaders = headers

// Drain the request body before responding (AWS SDK sends the full request before reading).
stream.resume()
// Capture the request body (AWS SDK sends the full request before reading the response).
const bodyChunks: Buffer[] = []
stream.on("data", (chunk: Buffer) => bodyChunks.push(chunk))
stream.on("end", () => {
try {
requestBodies.push(JSON.parse(Buffer.concat(bodyChunks).toString("utf8")))
} catch {
requestBodies.push(undefined)
}

stream.respond({
":status": 200,
"content-type": "application/vnd.amazon.eventstream",
})
const frames = buildToolCallFrames(
"attempt_completion",
"tooluse_bedrock_mock_001",
JSON.stringify({ result: "4" }),
)
const frames = responses[Math.min(requestCount, responses.length - 1)] ?? []
requestCount++
for (const frame of frames) {
stream.write(frame)
}
Expand All @@ -139,6 +178,9 @@ export async function startBedrockMockServer(): Promise<BedrockMockServer> {
get lastRequestHeaders() {
return lastRequestHeaders
},
get requestBodies() {
return requestBodies
},
close: () => {
// Destroy all open HTTP/2 sessions first so server.close() resolves immediately
// instead of waiting for the extension's retry loop to exhaust itself.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as assert from "assert"

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

import {
startBedrockMockServer,
buildEmptyResponseFrames,
buildToolCallFrames,
type BedrockMockServer,
} from "../../bedrock-mock-server"
import { setDefaultSuiteTimeout } from "../test-utils"
import { waitFor, waitUntilCompleted } from "../utils"

const BEDROCK_MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
const USER_PROMPT = "bedrock-empty-response-retry-smoke: what is 2+2? Reply with only the number."

suite("Bedrock provider — empty assistant response retry", function () {
setDefaultSuiteTimeout(this)
this.timeout(3 * 60_000)

let mockServer: BedrockMockServer | undefined

suiteTeardown(async () => {
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` }),
})

if (mockServer) {
await new Promise<void>((resolve) => setTimeout(resolve, 500))
await mockServer.close()
mockServer = undefined
}
})

// Regression test for a bug where the manual-retry path (autoApprovalEnabled: false,
// user clicks "retry" on the api_req_failed prompt after the model returns no assistant
// content) failed to mark userMessageWasRemoved on the retried stack item. That caused
// shouldAddUserMessageToHistory to skip re-adding the user's message entirely on retry,
// so the retried request went out without it -- silently dropping the user's turn.
test("re-sends the user message and completes after a manual retry following an empty assistant response", async () => {
mockServer = await startBedrockMockServer({
responses: [
buildEmptyResponseFrames(),
buildToolCallFrames("attempt_completion", "tooluse_bedrock_mock_002", JSON.stringify({ result: "4" })),
],
})

await globalThis.api.setConfiguration({
apiProvider: "bedrock" as const,
awsUseApiKey: true,
awsApiKey: "mock-key",
awsRegion: "us-east-1",
apiModelId: BEDROCK_MODEL_ID,
awsBedrockEndpoint: mockServer.url,
awsBedrockEndpointEnabled: true,
})

const api = globalThis.api
// Keyed by taskId: this suite runs after bedrock.test.ts's tests in the same
// extension host, and RooCodeEventName.Message is a global event stream, so a
// leftover ask from a prior test's task could otherwise be mistaken for this
// test's own "api_req_failed"/"completion_result" prompts.
const asksByTaskId: Record<string, ClineMessage[]> = {}
let ourTaskId: string | undefined

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
// Only track the final, non-partial ask -- approving a still-streaming/partial
// ask (e.g. mid-tool-execution) can interrupt the in-flight tool call, which the
// framework then reports as an error and retries, inflating the request count.
if (message.type === "ask" && message.partial !== true) {
;(asksByTaskId[taskId] ??= []).push(message)
}
}
api.on(RooCodeEventName.Message, messageHandler)

try {
await waitUntilCompleted({
api,
start: async () => {
const taskId = await api.startNewTask({
// autoApprovalEnabled: false is required so the empty-response retry
// goes through the manual ask("api_req_failed", ...) prompt path
// (the buggy branch) rather than the auto-retry backoff path.
configuration: { mode: "ask", autoApprovalEnabled: false },
text: USER_PROMPT,
})
ourTaskId = taskId

// Wait for the manual retry prompt, then approve it (equivalent to
// clicking the primary "Retry" button -- response: "yesButtonClicked").
await waitFor(() => asksByTaskId[taskId]?.some(({ ask }) => ask === "api_req_failed") ?? false)
await api.approveCurrentAsk()

// After the retry succeeds, the model calls attempt_completion, which
// prompts a separate "completion_result" ask -- approve that too so
// RooCodeEventName.TaskCompleted (what waitUntilCompleted waits on) fires.
await waitFor(() => asksByTaskId[taskId]?.some(({ ask }) => ask === "completion_result") ?? false)
await api.approveCurrentAsk()

return taskId
},
})
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}

const asks = ourTaskId ? (asksByTaskId[ourTaskId] ?? []) : []

assert.ok(
asks.some(({ ask }) => ask === "api_req_failed"),
"Should have prompted for retry after the empty assistant response",
)

// The task must have completed at all -- if the user message was dropped on
// retry (the bug), the retried request would still be missing the user's turn,
// and depending on API validation this could hang, error, or produce a
// nonsensical response instead of reaching completion via waitUntilCompleted above.
//
// The request count itself is intentionally >= 2 rather than exactly 2: after the
// retry succeeds, the framework's own tool_result-interruption recovery
// (validateAndFixToolResultIds, unrelated to this fix) can occasionally inject an
// extra self-correcting exchange if the attempt_completion tool result hasn't been
// recorded by the time the next turn is built. That's expected, independently
// tested framework behavior -- what this test cares about is specifically the
// *first retry request* (index 1, immediately after the empty response), which is
// exactly what the userMessageWasRemoved fix governs.
assert.ok(
mockServer.requestBodies.length >= 2,
`Should have made at least 2 requests (initial + retry), got ${mockServer.requestBodies.length}`,
)

const retryRequestBody = mockServer.requestBodies[1] as { messages?: Array<{ content?: unknown[] }> }
const retryRequestJson = JSON.stringify(retryRequestBody)

assert.ok(
retryRequestJson.includes("bedrock-empty-response-retry-smoke"),
"The retried request must still include the user's original message text " +
"(regression check: userMessageWasRemoved must be set so the message is re-added before retrying)",
)
})
})
10 changes: 9 additions & 1 deletion packages/telemetry/src/PostHogTelemetryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
super(
{
type: "exclude",
events: [TelemetryEventName.TASK_MESSAGE, TelemetryEventName.LLM_COMPLETION],
events: [
TelemetryEventName.TASK_MESSAGE,
TelemetryEventName.LLM_COMPLETION,
// Per-turn events superseded by the toolsUsed/messageCount summary on
// Task Completed (see TelemetryService.captureTaskCompleted). Excluded
// here as a backstop in case any call site still fires them directly.
TelemetryEventName.TASK_CONVERSATION_MESSAGE,
TelemetryEventName.TOOL_USED,
],
},
debug,
)
Expand Down
Loading
Loading