From 3581ebf192097cae611a057656ef13479f8c13c8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 13:46:32 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20disable=20extended=20?= =?UTF-8?q?thinking=20for=20workspace=20name=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When generating workspace names with reasoning models (e.g., claude-opus-4-5), the API sometimes returns thinking content that breaks JSON parsing, causing 'Invalid JSON response' errors. Fix: Explicitly pass providerOptions with thinking disabled to generateObject(). - Add buildProviderOptions(modelString, 'off') to ensure no thinking content - Add ModelProvider interface for easier testing without full AIService - Add unit/integration tests for workspace title generation - Add testUtils.ts with minimal model creation for tests --- src/node/services/testUtils.ts | 42 ++++++++++ .../services/workspaceTitleGenerator.test.ts | 82 +++++++++++++++++++ src/node/services/workspaceTitleGenerator.ts | 20 ++++- 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/node/services/testUtils.ts create mode 100644 src/node/services/workspaceTitleGenerator.test.ts diff --git a/src/node/services/testUtils.ts b/src/node/services/testUtils.ts new file mode 100644 index 0000000000..5a27bb08de --- /dev/null +++ b/src/node/services/testUtils.ts @@ -0,0 +1,42 @@ +/** + * Test utilities for AI service tests + * + * Provides minimal implementations for testing without full service infrastructure. + */ + +import type { LanguageModel } from "ai"; +import type { Result } from "@/common/types/result"; +import { Ok, Err } from "@/common/types/result"; +import type { SendMessageError } from "@/common/types/errors"; + +/** + * Create a model for testing - minimal implementation that uses the AI SDK directly. + * Supports Anthropic models only (add more providers as needed). + */ +export async function createModelForTest( + modelString: string +): Promise> { + const [provider, modelId] = modelString.split(":"); + + if (!provider || !modelId) { + return Err({ + type: "invalid_model_string", + message: `Invalid model string: ${modelString}`, + }); + } + + if (provider === "anthropic") { + const apiKey = process.env.ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_AUTH_TOKEN; + if (!apiKey) { + return Err({ type: "api_key_not_found", provider: "anthropic" }); + } + + // Dynamic import is acceptable here - test utility only, not production code + // eslint-disable-next-line no-restricted-syntax + const { createAnthropic } = await import("@ai-sdk/anthropic"); + const anthropic = createAnthropic({ apiKey }); + return Ok(anthropic(modelId)); + } + + return Err({ type: "provider_not_supported", provider }); +} diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts new file mode 100644 index 0000000000..f054101498 --- /dev/null +++ b/src/node/services/workspaceTitleGenerator.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for workspace title generator + * + * Uses real API calls (TEST_INTEGRATION=1) to verify: + * - Small prompts work correctly + * - Reasoning/thinking models don't break JSON parsing + */ + +import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator"; +import { createModelForTest } from "./testUtils"; + +// Unit tests - always run +describe("generatePlaceholderName", () => { + it("should generate git-safe placeholder from message", () => { + expect(generatePlaceholderName("Fix the login bug")).toBe("fix-the-login-bug"); + }); + + it("should handle empty message", () => { + expect(generatePlaceholderName("")).toBe("new-workspace"); + }); + + it("should truncate long messages", () => { + const longMessage = + "This is a very long message that should be truncated to fit within the limit"; + const result = generatePlaceholderName(longMessage); + expect(result.length).toBeLessThanOrEqual(30); + }); + + it("should sanitize special characters", () => { + expect(generatePlaceholderName("Fix: the @login #bug!")).toBe("fix-the-login-bug"); + }); +}); + +// Integration tests - require TEST_INTEGRATION=1 +const describeIntegration = process.env.TEST_INTEGRATION === "1" ? describe : describe.skip; + +describeIntegration("generateWorkspaceName - integration", () => { + // Minimal AIService-like object that only provides createModel + const aiService = { createModel: createModelForTest }; + + // Test with a small prompt that triggered the bug + it("should handle small prompts with opus-4-5 (reasoning model)", async () => { + const message = "Solve https://github.com/coder/registry/issues/42"; + const model = "anthropic:claude-opus-4-5"; + + const result = await generateWorkspaceName(message, model, aiService); + + // The result should be successful, not fail with "Invalid JSON response" + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatch(/^[a-z0-9-]+$/); + expect(result.data.length).toBeGreaterThanOrEqual(3); + expect(result.data.length).toBeLessThanOrEqual(50); + } + }, 30000); + + // Test with very short prompt + it("should handle very short prompts", async () => { + const message = "fix bug"; + const model = "anthropic:claude-opus-4-5"; + + const result = await generateWorkspaceName(message, model, aiService); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatch(/^[a-z0-9-]+$/); + } + }, 30000); + + // Test with claude-sonnet-4-5 (thinking model but different config) + it("should handle sonnet-4-5 with small prompts", async () => { + const message = "update README"; + const model = "anthropic:claude-sonnet-4-5"; + + const result = await generateWorkspaceName(message, model, aiService); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatch(/^[a-z0-9-]+$/); + } + }, 30000); +}); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index d83f4e0c24..f8d13f7c1d 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -1,10 +1,10 @@ -import { generateObject } from "ai"; +import { generateObject, type LanguageModel } from "ai"; import { z } from "zod"; -import type { AIService } from "./aiService"; import { log } from "./log"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { SendMessageError } from "@/common/types/errors"; +import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; const workspaceNameSchema = z.object({ name: z @@ -15,15 +15,22 @@ const workspaceNameSchema = z.object({ .describe("Git-safe branch/workspace name: lowercase, hyphens only"), }); +/** Minimal interface for model creation - allows testing without full AIService */ +interface ModelProvider { + createModel(modelString: string): Promise>; +} + /** * Generate workspace name using AI. * If AI cannot be used (e.g. missing credentials, unsupported provider, invalid model), * returns a SendMessageError so callers can surface the standard provider error UX. + * + * Explicitly disables extended thinking to ensure clean JSON responses. */ export async function generateWorkspaceName( message: string, modelString: string, - aiService: AIService + aiService: ModelProvider ): Promise> { try { const modelResult = await aiService.createModel(modelString); @@ -31,10 +38,17 @@ export async function generateWorkspaceName( return Err(modelResult.error); } + // Explicitly disable extended thinking for workspace name generation. + // Reasoning models sometimes return thinking content that breaks JSON parsing. + const providerOptions = buildProviderOptions(modelString, "off"); + const result = await generateObject({ model: modelResult.data, schema: workspaceNameSchema, prompt: `Generate a git-safe branch/workspace name for this development task:\n\n"${message}"\n\nRequirements:\n- Git-safe identifier (e.g., "automatic-title-generation")\n- Lowercase, hyphens only, no spaces\n- Concise (2-5 words) and descriptive of the task`, + // Cast needed: our ProviderOptions type is stricter than AI SDK's generic type + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + providerOptions: providerOptions as any, }); return Ok(validateBranchName(result.object.name)); From c654f90958d6954ceefe1c5de1a9c6c79f8dc389 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 13:54:46 -0600 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20graceful=20fallback?= =?UTF-8?q?=20and=20better=20logging=20for=20workspace=20name=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When workspace name generation fails (e.g., proxy returns invalid JSON): - Fall back to placeholder name instead of blocking workspace creation - Show warning toast with error details - Log raw response body for debugging proxy issues Also: - Add warning toast type with theme-aware colors - Explicitly disable extended thinking for generateObject calls --- .../ChatInput/useCreationWorkspace.ts | 13 +++++++ src/browser/components/ChatInputToast.tsx | 5 ++- src/browser/styles/globals.css | 12 ++++++ src/node/services/ipcMain.ts | 38 ++++++++----------- src/node/services/workspaceTitleGenerator.ts | 10 +++++ 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 57f587577a..b91bad1150 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -131,6 +131,19 @@ export function useCreationWorkspace({ const pendingInputKey = getInputKey(getPendingScopeId(projectPath)); updatePersistedState(pendingInputKey, ""); } + + // Show warning toast if name generation had issues (e.g., proxy errors) + // Workspace was still created with placeholder name + const warning = "warning" in result ? (result.warning as string | undefined) : undefined; + if (warning) { + setToast({ + id: Date.now().toString(), + type: "warning", + title: "Workspace Created", + message: warning, + }); + } + // Settings are already persisted via useDraftWorkspaceSettings // Notify parent to switch workspace (clears input via parent unmount) onWorkspaceCreated(result.metadata); diff --git a/src/browser/components/ChatInputToast.tsx b/src/browser/components/ChatInputToast.tsx index 2a4a40b227..a22e19b911 100644 --- a/src/browser/components/ChatInputToast.tsx +++ b/src/browser/components/ChatInputToast.tsx @@ -2,14 +2,15 @@ import type { ReactNode } from "react"; import React, { useEffect, useCallback } from "react"; import { cn } from "@/common/lib/utils"; -const toastTypeStyles: Record<"success" | "error", string> = { +const toastTypeStyles: Record<"success" | "error" | "warning", string> = { success: "bg-toast-success-bg border border-accent-dark text-toast-success-text", error: "bg-toast-error-bg border border-toast-error-border text-toast-error-text", + warning: "bg-toast-warning-bg border border-toast-warning-border text-toast-warning-text", }; export interface Toast { id: string; - type: "success" | "error"; + type: "success" | "error" | "warning"; title?: string; message: string; solution?: ReactNode; diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index cc7015fe0f..92786385c0 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -223,6 +223,9 @@ --color-toast-error-bg: hsl(5 89% 60% / 0.15); /* #f14836 with 15% opacity */ --color-toast-error-text: hsl(5 89% 60%); /* #f14836 */ --color-toast-error-border: hsl(5 89% 60%); /* #f14836 */ + --color-toast-warning-bg: hsl(45 100% 51% / 0.15); /* warning yellow with 15% opacity */ + --color-toast-warning-text: hsl(45 100% 51%); /* warning yellow */ + --color-toast-warning-border: hsl(45 100% 51%); /* warning yellow */ --color-toast-fatal-bg: hsl(0 33% 18%); /* #2d1f1f - fatal error bg */ --color-toast-fatal-border: hsl(0 36% 26%); /* #5a2c2c - fatal error border */ @@ -442,6 +445,9 @@ --color-toast-error-bg: hsl(5 80% 55% / 0.18); --color-toast-error-text: hsl(5 78% 46%); --color-toast-error-border: hsl(5 78% 46%); + --color-toast-warning-bg: hsl(45 100% 50% / 0.18); + --color-toast-warning-text: hsl(45 100% 35%); + --color-toast-warning-border: hsl(45 100% 35%); --color-toast-fatal-bg: hsl(0 72% 94%); --color-toast-fatal-border: hsl(0 74% 82%); @@ -662,6 +668,9 @@ --color-toast-error-bg: hsla(1 79% 53% / 0.18); --color-toast-error-text: #dc322f; --color-toast-error-border: #dc322f; + --color-toast-warning-bg: hsla(45 100% 35% / 0.18); + --color-toast-warning-text: #b58900; + --color-toast-warning-border: #b58900; --color-toast-fatal-bg: #fce8e7; --color-toast-fatal-border: #e8a5a3; @@ -867,6 +876,9 @@ --color-toast-error-bg: hsla(1 79% 53% / 0.15); --color-toast-error-text: #dc322f; --color-toast-error-border: #dc322f; + --color-toast-warning-bg: hsla(45 100% 35% / 0.15); + --color-toast-warning-text: #b58900; + --color-toast-warning-border: #b58900; --color-toast-fatal-bg: #2d1f1f; --color-toast-fatal-border: #5a2c2c; diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 6129544767..e384f5876a 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -301,7 +301,7 @@ export class IpcMain { trunkBranch?: string; } ): Promise< - | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } + | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata; warning?: string } | Result > { // Generate IDs and placeholder upfront for immediate UI feedback @@ -333,33 +333,26 @@ export class IpcMain { try { // 1. Generate workspace branch name using AI (SLOW - but user sees pending state) + // Falls back to placeholder name if AI fails - don't block workspace creation let branchName: string; + let nameGenerationWarning: string | undefined; { - const isErrLike = (v: unknown): v is { type: string } => - typeof v === "object" && v !== null && "type" in v; const nameResult = await generateWorkspaceName(message, options.model, this.aiService); - if (!nameResult.success) { - const err = nameResult.error; - // Clear pending state on error - session.emitMetadata(null); - if (isErrLike(err)) { - return Err(err); - } - const toSafeString = (v: unknown): string => { - if (v instanceof Error) return v.message; - try { - return JSON.stringify(v); - } catch { - return String(v); - } - }; - const msg = toSafeString(err); - return Err({ type: "unknown", raw: `Failed to generate workspace name: ${msg}` }); + if (nameResult.success) { + branchName = nameResult.data; + } else { + // Fall back to placeholder name - don't block workspace creation + branchName = generatePlaceholderName(message); + const errMsg = + nameResult.error.type === "unknown" && "raw" in nameResult.error + ? nameResult.error.raw + : `Name generation failed: ${nameResult.error.type}`; + nameGenerationWarning = errMsg; + log.info("Falling back to placeholder name", { branchName, error: nameResult.error }); } - branchName = nameResult.data; } - log.debug("Generated workspace name", { branchName }); + log.debug("Generated workspace name", { branchName, warning: nameGenerationWarning }); // 2. Get trunk branch (use provided trunkBranch or auto-detect) const branches = await listLocalBranches(projectPath); @@ -478,6 +471,7 @@ export class IpcMain { success: true, workspaceId, metadata: completeMetadata, + warning: nameGenerationWarning, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index f8d13f7c1d..ef8a58e9ee 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -54,7 +54,17 @@ export async function generateWorkspaceName( return Ok(validateBranchName(result.object.name)); } catch (error) { const messageText = error instanceof Error ? error.message : String(error); + + // Log the raw response body if available (helps debug proxy issues) + const responseBody = + error && typeof error === "object" && "responseBody" in error + ? (error as { responseBody?: unknown }).responseBody + : undefined; + if (responseBody) { + log.error("Failed to generate workspace name - raw response:", responseBody); + } log.error("Failed to generate workspace name with AI", error); + return Err({ type: "unknown", raw: `Failed to generate workspace name: ${messageText}` }); } }