Skip to content
Closed
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
13 changes: 13 additions & 0 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/browser/components/ChatInputToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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%);

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
38 changes: 16 additions & 22 deletions src/node/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, SendMessageError>
> {
// Generate IDs and placeholder upfront for immediate UI feedback
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions src/node/services/testUtils.ts
Original file line number Diff line number Diff line change
@@ -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<Result<LanguageModel, SendMessageError>> {
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 });
}
82 changes: 82 additions & 0 deletions src/node/services/workspaceTitleGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
30 changes: 27 additions & 3 deletions src/node/services/workspaceTitleGenerator.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,32 +15,56 @@ 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<Result<LanguageModel, SendMessageError>>;
}

/**
* 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<Result<string, SendMessageError>> {
try {
const modelResult = await aiService.createModel(modelString);
if (!modelResult.success) {
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));
} 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}` });
}
}
Expand Down