m.metadata?.historySequence ?? 0)
+ );
+ const errorMessage: MuxMessage = {
+ id: data.messageId,
+ role: "assistant",
+ parts: [],
+ metadata: {
+ partial: true,
+ error: data.error,
+ errorType: data.errorType,
+ timestamp: Date.now(),
+ historySequence: maxSequence + 1,
+ },
+ };
+ this.messages.set(data.messageId, errorMessage);
+ this.invalidateCache();
}
}
diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts
index 35d5495d7..999aeb6bf 100644
--- a/src/node/runtime/WorktreeRuntime.ts
+++ b/src/node/runtime/WorktreeRuntime.ts
@@ -177,14 +177,25 @@ export class WorktreeRuntime extends LocalBaseRuntime {
const newPath = this.getWorkspacePath(projectPath, newName);
try {
- // Use git worktree move to rename the worktree directory
- // This updates git's internal worktree metadata correctly
- using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
- await proc.result;
+ // Move the worktree directory (updates git's internal worktree metadata)
+ using moveProc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
+ await moveProc.result;
+
+ // Rename the git branch to match the new workspace name
+ // In mux, branch name and workspace name are always kept in sync.
+ // Run from the new worktree path since that's where the branch is checked out.
+ // Best-effort: ignore errors (e.g., branch might have a different name in test scenarios).
+ try {
+ using branchProc = execAsync(`git -C "${newPath}" branch -m "${oldName}" "${newName}"`);
+ await branchProc.result;
+ } catch {
+ // Branch rename failed - this is fine, the directory was still moved
+ // This can happen if the branch name doesn't match the old directory name
+ }
return { success: true, oldPath, newPath };
} catch (error) {
- return { success: false, error: `Failed to move worktree: ${getErrorMessage(error)}` };
+ return { success: false, error: `Failed to rename workspace: ${getErrorMessage(error)}` };
}
}
diff --git a/src/node/services/utils/sendMessageError.test.ts b/src/node/services/utils/sendMessageError.test.ts
new file mode 100644
index 000000000..e34492fc9
--- /dev/null
+++ b/src/node/services/utils/sendMessageError.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test";
+import { formatSendMessageError, createUnknownSendMessageError } from "./sendMessageError";
+
+describe("formatSendMessageError", () => {
+ test("formats api_key_not_found with authentication errorType", () => {
+ const result = formatSendMessageError({
+ type: "api_key_not_found",
+ provider: "anthropic",
+ });
+
+ expect(result.errorType).toBe("authentication");
+ expect(result.message).toContain("anthropic");
+ expect(result.message).toContain("API key");
+ });
+
+ test("formats provider_not_supported", () => {
+ const result = formatSendMessageError({
+ type: "provider_not_supported",
+ provider: "unsupported-provider",
+ });
+
+ expect(result.errorType).toBe("unknown");
+ expect(result.message).toContain("unsupported-provider");
+ expect(result.message).toContain("not supported");
+ });
+
+ test("formats invalid_model_string with model_not_found errorType", () => {
+ const result = formatSendMessageError({
+ type: "invalid_model_string",
+ message: "Invalid model format: foo",
+ });
+
+ expect(result.errorType).toBe("model_not_found");
+ expect(result.message).toBe("Invalid model format: foo");
+ });
+
+ test("formats incompatible_workspace", () => {
+ const result = formatSendMessageError({
+ type: "incompatible_workspace",
+ message: "Workspace is incompatible",
+ });
+
+ expect(result.errorType).toBe("unknown");
+ expect(result.message).toBe("Workspace is incompatible");
+ });
+
+ test("formats unknown errors", () => {
+ const result = formatSendMessageError({
+ type: "unknown",
+ raw: "Something went wrong",
+ });
+
+ expect(result.errorType).toBe("unknown");
+ expect(result.message).toBe("Something went wrong");
+ });
+});
+
+describe("createUnknownSendMessageError", () => {
+ test("creates unknown error with trimmed message", () => {
+ const result = createUnknownSendMessageError(" test error ");
+
+ expect(result).toEqual({ type: "unknown", raw: "test error" });
+ });
+
+ test("throws on empty message", () => {
+ expect(() => createUnknownSendMessageError("")).toThrow();
+ expect(() => createUnknownSendMessageError(" ")).toThrow();
+ });
+});
diff --git a/src/node/services/utils/sendMessageError.ts b/src/node/services/utils/sendMessageError.ts
index 4e2e9812a..0ef4f0bd8 100644
--- a/src/node/services/utils/sendMessageError.ts
+++ b/src/node/services/utils/sendMessageError.ts
@@ -1,5 +1,5 @@
import assert from "@/common/utils/assert";
-import type { SendMessageError } from "@/common/types/errors";
+import type { SendMessageError, StreamErrorType } from "@/common/types/errors";
/**
* Helper to wrap arbitrary errors into SendMessageError structures.
@@ -15,3 +15,39 @@ export const createUnknownSendMessageError = (raw: string): SendMessageError =>
raw: trimmed,
};
};
+
+/**
+ * Formats a SendMessageError into a user-visible message and StreamErrorType
+ * for display in the chat UI as a stream-error event.
+ */
+export const formatSendMessageError = (
+ error: SendMessageError
+): { message: string; errorType: StreamErrorType } => {
+ switch (error.type) {
+ case "api_key_not_found":
+ return {
+ message: `API key not configured for ${error.provider}. Please add your API key in settings.`,
+ errorType: "authentication",
+ };
+ case "provider_not_supported":
+ return {
+ message: `Provider "${error.provider}" is not supported.`,
+ errorType: "unknown",
+ };
+ case "invalid_model_string":
+ return {
+ message: error.message,
+ errorType: "model_not_found",
+ };
+ case "incompatible_workspace":
+ return {
+ message: error.message,
+ errorType: "unknown",
+ };
+ case "unknown":
+ return {
+ message: error.raw,
+ errorType: "unknown",
+ };
+ }
+};
diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts
new file mode 100644
index 000000000..1cd485c57
--- /dev/null
+++ b/src/node/services/workspaceService.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, test, mock, beforeEach } from "bun:test";
+import { WorkspaceService } from "./workspaceService";
+import type { Config } from "@/node/config";
+import type { HistoryService } from "./historyService";
+import type { PartialService } from "./partialService";
+import type { AIService } from "./aiService";
+import type { InitStateManager } from "./initStateManager";
+import type { ExtensionMetadataService } from "./ExtensionMetadataService";
+
+// Helper to access private renamingWorkspaces set
+function addToRenamingWorkspaces(service: WorkspaceService, workspaceId: string): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
+ (service as any).renamingWorkspaces.add(workspaceId);
+}
+
+describe("WorkspaceService rename lock", () => {
+ let workspaceService: WorkspaceService;
+ let mockAIService: AIService;
+
+ beforeEach(() => {
+ // Create minimal mocks for the services
+ mockAIService = {
+ isStreaming: mock(() => false),
+ getWorkspaceMetadata: mock(() => Promise.resolve({ success: false, error: "not found" })),
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ on: mock(() => {}),
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ off: mock(() => {}),
+ } as unknown as AIService;
+
+ const mockHistoryService: Partial
= {
+ getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })),
+ appendToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })),
+ };
+
+ const mockConfig: Partial = {
+ srcDir: "/tmp/test",
+ getSessionDir: mock(() => "/tmp/test/sessions"),
+ generateStableId: mock(() => "test-id"),
+ findWorkspace: mock(() => null),
+ };
+
+ const mockPartialService: Partial = {
+ commitToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })),
+ };
+
+ const mockInitStateManager: Partial = {};
+ const mockExtensionMetadataService: Partial = {};
+
+ workspaceService = new WorkspaceService(
+ mockConfig as Config,
+ mockHistoryService as HistoryService,
+ mockPartialService as PartialService,
+ mockAIService,
+ mockInitStateManager as InitStateManager,
+ mockExtensionMetadataService as ExtensionMetadataService
+ );
+ });
+
+ test("sendMessage returns error when workspace is being renamed", async () => {
+ const workspaceId = "test-workspace";
+
+ addToRenamingWorkspaces(workspaceService, workspaceId);
+
+ const result = await workspaceService.sendMessage(workspaceId, "test message", {
+ model: "test-model",
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error;
+ // Error is SendMessageError which has a discriminated union
+ expect(typeof error === "object" && error.type === "unknown").toBe(true);
+ if (typeof error === "object" && error.type === "unknown") {
+ expect(error.raw).toContain("being renamed");
+ }
+ }
+ });
+
+ test("resumeStream returns error when workspace is being renamed", async () => {
+ const workspaceId = "test-workspace";
+
+ addToRenamingWorkspaces(workspaceService, workspaceId);
+
+ const result = await workspaceService.resumeStream(workspaceId, {
+ model: "test-model",
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const error = result.error;
+ // Error is SendMessageError which has a discriminated union
+ expect(typeof error === "object" && error.type === "unknown").toBe(true);
+ if (typeof error === "object" && error.type === "unknown") {
+ expect(error.raw).toContain("being renamed");
+ }
+ }
+ });
+
+ test("rename returns error when workspace is streaming", async () => {
+ const workspaceId = "test-workspace";
+
+ // Mock isStreaming to return true
+ (mockAIService.isStreaming as ReturnType).mockReturnValue(true);
+
+ const result = await workspaceService.rename(workspaceId, "new-name");
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error).toContain("stream is active");
+ }
+ });
+});
diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts
index 3c5633e64..66b1032e1 100644
--- a/src/node/services/workspaceService.ts
+++ b/src/node/services/workspaceService.ts
@@ -14,14 +14,16 @@ import type { InitStateManager } from "@/node/services/initStateManager";
import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService";
import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git";
import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory";
-import { generateWorkspaceName } from "./workspaceTitleGenerator";
+import { generateWorkspaceName, generatePlaceholderName } from "./workspaceTitleGenerator";
import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation";
+import { formatSendMessageError } from "@/node/services/utils/sendMessageError";
import type {
SendMessageOptions,
DeleteMessage,
ImagePart,
WorkspaceChatMessage,
+ StreamErrorMessage,
} from "@/common/orpc/types";
import type { SendMessageError } from "@/common/types/errors";
import type {
@@ -80,6 +82,8 @@ export class WorkspaceService extends EventEmitter {
string,
{ chat: () => void; metadata: () => void }
>();
+ // Tracks workspaces currently being renamed to prevent streaming during rename
+ private readonly renamingWorkspaces = new Set();
constructor(
private readonly config: Config,
@@ -403,7 +407,7 @@ export class WorkspaceService extends EventEmitter {
}
}
- async createForFirstMessage(
+ createForFirstMessage(
message: string,
projectPath: string,
options: SendMessageOptions & {
@@ -411,45 +415,88 @@ export class WorkspaceService extends EventEmitter {
runtimeConfig?: RuntimeConfig;
trunkBranch?: string;
} = { model: "claude-3-5-sonnet-20241022" }
- ): Promise<
+ ):
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
- | { success: false; error: string }
- > {
- try {
- const branchNameResult = await generateWorkspaceName(message, options.model, this.aiService);
- if (!branchNameResult.success) {
- const err = branchNameResult.error;
- const errorMessage =
- "message" in err
- ? err.message
- : err.type === "api_key_not_found"
- ? `API key not found for ${err.provider}`
- : err.type === "provider_not_supported"
- ? `Provider not supported: ${err.provider}`
- : "raw" in err
- ? err.raw
- : "Unknown error";
- return { success: false, error: errorMessage };
- }
- const branchName = branchNameResult.data;
- log.debug("Generated workspace name", { branchName });
+ | { success: false; error: string } {
+ // Generate placeholder name and ID immediately (non-blocking)
+ const placeholderName = generatePlaceholderName(message);
+ const workspaceId = this.config.generateStableId();
+ const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
- const branches = await listLocalBranches(projectPath);
- const recommendedTrunk =
- options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main";
+ // Use provided runtime config or default to worktree
+ const runtimeConfig: RuntimeConfig = options.runtimeConfig ?? {
+ type: "worktree",
+ srcBaseDir: this.config.srcDir,
+ };
- // Default to worktree runtime for backward compatibility
- let finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? {
- type: "worktree",
- srcBaseDir: this.config.srcDir,
- };
+ // Compute preliminary workspace path (may be refined after srcBaseDir resolution)
+ const srcBaseDir = getSrcBaseDir(runtimeConfig) ?? this.config.srcDir;
+ const preliminaryWorkspacePath = path.join(srcBaseDir, projectName, placeholderName);
+
+ // Create preliminary metadata with "creating" status for immediate UI response
+ const preliminaryMetadata: FrontendWorkspaceMetadata = {
+ id: workspaceId,
+ name: placeholderName,
+ projectName,
+ projectPath,
+ createdAt: new Date().toISOString(),
+ namedWorkspacePath: preliminaryWorkspacePath,
+ runtimeConfig,
+ status: "creating",
+ };
+
+ // Create session and emit metadata immediately so frontend can switch
+ const session = this.getOrCreateSession(workspaceId);
+ session.emitMetadata(preliminaryMetadata);
+
+ log.debug("Emitted preliminary workspace metadata", { workspaceId, placeholderName });
- const workspaceId = this.config.generateStableId();
+ // Kick off background workspace creation (git operations, config save, etc.)
+ void this.completeWorkspaceCreation(
+ workspaceId,
+ message,
+ projectPath,
+ placeholderName,
+ runtimeConfig,
+ options
+ );
+
+ // Return immediately with preliminary metadata
+ return {
+ success: true,
+ workspaceId,
+ metadata: preliminaryMetadata,
+ };
+ }
+
+ /**
+ * Completes workspace creation in the background after preliminary metadata is emitted.
+ * Handles git operations, config persistence, and kicks off message sending.
+ */
+ private async completeWorkspaceCreation(
+ workspaceId: string,
+ message: string,
+ projectPath: string,
+ placeholderName: string,
+ runtimeConfig: RuntimeConfig,
+ options: SendMessageOptions & {
+ imageParts?: Array<{ url: string; mediaType: string }>;
+ runtimeConfig?: RuntimeConfig;
+ trunkBranch?: string;
+ }
+ ): Promise {
+ const session = this.sessions.get(workspaceId);
+ if (!session) {
+ log.error("Session not found for workspace creation", { workspaceId });
+ return;
+ }
+ try {
+ // Resolve runtime config (may involve path resolution for SSH)
+ let finalRuntimeConfig = runtimeConfig;
let runtime;
try {
runtime = createRuntime(finalRuntimeConfig, { projectPath });
- // Resolve srcBaseDir path if the config has one
const srcBaseDir = getSrcBaseDir(finalRuntimeConfig);
if (srcBaseDir) {
const resolvedSrcBaseDir = await runtime.resolvePath(srcBaseDir);
@@ -463,15 +510,21 @@ export class WorkspaceService extends EventEmitter {
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
- return { success: false, error: errorMsg };
+ log.error("Failed to create runtime for workspace", { workspaceId, error: errorMsg });
+ session.emitMetadata(null); // Remove the "creating" workspace
+ return;
}
- const session = this.getOrCreateSession(workspaceId);
+ // Detect trunk branch (git operation)
+ const branches = await listLocalBranches(projectPath);
+ const recommendedTrunk =
+ options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main";
+
this.initStateManager.startInit(workspaceId, projectPath);
const initLogger = this.createInitLogger(workspaceId);
// Create workspace with automatic collision retry
- let finalBranchName = branchName;
+ let finalBranchName = placeholderName;
let createResult: { success: boolean; workspacePath?: string; error?: string };
for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) {
@@ -485,38 +538,32 @@ export class WorkspaceService extends EventEmitter {
if (createResult.success) break;
- // If collision and not last attempt, retry with suffix
if (
isWorkspaceNameCollision(createResult.error) &&
attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
) {
log.debug(`Workspace name collision for "${finalBranchName}", retrying with suffix`);
- finalBranchName = appendCollisionSuffix(branchName);
+ finalBranchName = appendCollisionSuffix(placeholderName);
continue;
}
break;
}
if (!createResult!.success || !createResult!.workspacePath) {
- return { success: false, error: createResult!.error ?? "Failed to create workspace" };
+ log.error("Failed to create workspace", {
+ workspaceId,
+ error: createResult!.error,
+ });
+ session.emitMetadata(null); // Remove the "creating" workspace
+ return;
}
const projectName =
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
-
- // Compute namedWorkspacePath
const namedWorkspacePath = runtime.getWorkspacePath(projectPath, finalBranchName);
+ const createdAt = new Date().toISOString();
- const metadata: FrontendWorkspaceMetadata = {
- id: workspaceId,
- name: finalBranchName,
- projectName,
- projectPath,
- createdAt: new Date().toISOString(),
- namedWorkspacePath,
- runtimeConfig: finalRuntimeConfig,
- };
-
+ // Save to config
await this.config.editConfig((config) => {
let projectConfig = config.projects.get(projectPath);
if (!projectConfig) {
@@ -527,21 +574,27 @@ export class WorkspaceService extends EventEmitter {
path: createResult!.workspacePath!,
id: workspaceId,
name: finalBranchName,
- createdAt: metadata.createdAt,
+ createdAt,
runtimeConfig: finalRuntimeConfig,
});
return config;
});
- const allMetadata = await this.config.getAllWorkspaceMetadata();
- const completeMetadata = allMetadata.find((m) => m.id === workspaceId);
-
- if (!completeMetadata) {
- return { success: false, error: "Failed to retrieve workspace metadata" };
- }
+ // Emit final metadata (without "creating" status)
+ const finalMetadata: FrontendWorkspaceMetadata = {
+ id: workspaceId,
+ name: finalBranchName,
+ projectName,
+ projectPath,
+ createdAt,
+ namedWorkspacePath,
+ runtimeConfig: finalRuntimeConfig,
+ };
+ session.emitMetadata(finalMetadata);
- session.emitMetadata(completeMetadata);
+ log.debug("Workspace creation completed", { workspaceId, finalBranchName });
+ // Start workspace initialization in background
void runtime
.initWorkspace({
projectPath,
@@ -557,20 +610,171 @@ export class WorkspaceService extends EventEmitter {
initLogger.logComplete(-1);
});
- void session.sendMessage(message, options);
+ // Send the first message, surfacing errors to the chat UI
+ void session.sendMessage(message, options).then((result) => {
+ if (!result.success) {
+ log.error("sendMessage failed during workspace creation", {
+ workspaceId,
+ errorType: result.error.type,
+ error: result.error,
+ });
+ const { message: errorMessage, errorType } = formatSendMessageError(result.error);
+ const streamError: StreamErrorMessage = {
+ type: "stream-error",
+ messageId: `error-${Date.now()}`,
+ error: errorMessage,
+ errorType,
+ };
+ session.emitChatEvent(streamError);
+ }
+ });
- return {
- success: true,
- workspaceId,
- metadata: completeMetadata,
- };
+ // Generate AI name asynchronously and rename if successful
+ void this.generateAndApplyAIName(workspaceId, message, finalBranchName, options.model);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error("Unexpected error in workspace creation", { workspaceId, error: errorMessage });
+ session.emitMetadata(null); // Remove the "creating" workspace
+ }
+ }
+
+ /**
+ * Asynchronously generates an AI workspace name and renames the workspace if successful.
+ * This runs in the background after workspace creation to avoid blocking the UX.
+ *
+ * The method:
+ * 1. Generates the AI name (can run while stream is active)
+ * 2. Waits for any active stream to complete (rename is blocked during streaming)
+ * 3. Attempts to rename the workspace
+ */
+ private async generateAndApplyAIName(
+ workspaceId: string,
+ message: string,
+ currentName: string,
+ model: string
+ ): Promise {
+ try {
+ log.debug("Starting async AI name generation", { workspaceId, currentName });
+
+ const branchNameResult = await generateWorkspaceName(message, model, this.aiService);
+
+ if (!branchNameResult.success) {
+ // AI name generation failed - keep the placeholder name
+ const err = branchNameResult.error;
+ const errorMessage =
+ "message" in err
+ ? err.message
+ : err.type === "api_key_not_found"
+ ? `API key not found for ${err.provider}`
+ : err.type === "provider_not_supported"
+ ? `Provider not supported: ${err.provider}`
+ : "raw" in err
+ ? err.raw
+ : "Unknown error";
+ log.info("AI name generation failed, keeping placeholder name", {
+ workspaceId,
+ currentName,
+ error: errorMessage,
+ });
+ return;
+ }
+
+ const aiGeneratedName = branchNameResult.data;
+ log.debug("AI generated workspace name", { workspaceId, aiGeneratedName, currentName });
+
+ // Only rename if the AI name is different from current name
+ if (aiGeneratedName === currentName) {
+ log.debug("AI name matches placeholder, no rename needed", { workspaceId });
+ return;
+ }
+
+ // Wait for the stream to complete before renaming (rename is blocked during streaming)
+ await this.waitForStreamComplete(workspaceId);
+
+ // Mark workspace as renaming to block new streams during the rename operation
+ this.renamingWorkspaces.add(workspaceId);
+ try {
+ // Attempt to rename with collision retry (same logic as workspace creation)
+ let finalName = aiGeneratedName;
+ for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) {
+ const renameResult = await this.rename(workspaceId, finalName);
+
+ if (renameResult.success) {
+ log.info("Successfully renamed workspace to AI-generated name", {
+ workspaceId,
+ oldName: currentName,
+ newName: finalName,
+ });
+ return;
+ }
+
+ // If collision and not last attempt, retry with suffix
+ if (
+ renameResult.error?.includes("already exists") &&
+ attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
+ ) {
+ log.debug(`Workspace name collision for "${finalName}", retrying with suffix`);
+ finalName = appendCollisionSuffix(aiGeneratedName);
+ continue;
+ }
+
+ // Non-collision error or out of retries - keep placeholder name
+ log.info("Failed to rename workspace to AI-generated name", {
+ workspaceId,
+ aiGeneratedName: finalName,
+ error: renameResult.error,
+ });
+ return;
+ }
+ } finally {
+ // Always clear renaming flag, even on error
+ this.renamingWorkspaces.delete(workspaceId);
+ }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
- log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
- return { success: false, error: `Failed to create workspace: ${errorMessage}` };
+ log.error("Unexpected error in async AI name generation", {
+ workspaceId,
+ error: errorMessage,
+ });
}
}
+ /**
+ * Waits for an active stream on the workspace to complete.
+ * Returns immediately if no stream is active.
+ */
+ private waitForStreamComplete(workspaceId: string): Promise {
+ // If not currently streaming, resolve immediately
+ if (!this.aiService.isStreaming(workspaceId)) {
+ return Promise.resolve();
+ }
+
+ log.debug("Waiting for stream to complete before rename", { workspaceId });
+
+ return new Promise((resolve) => {
+ // Create handler that checks for this workspace's stream end
+ const handler = (event: StreamEndEvent | StreamAbortEvent) => {
+ if (event.workspaceId === workspaceId) {
+ this.aiService.off("stream-end", handler);
+ this.aiService.off("stream-abort", handler);
+ log.debug("Stream completed, proceeding with rename", { workspaceId });
+ resolve();
+ }
+ };
+
+ // Listen for both normal completion and abort
+ this.aiService.on("stream-end", handler);
+ this.aiService.on("stream-abort", handler);
+
+ // Safety check: if stream already ended between the isStreaming check and subscribing
+ if (!this.aiService.isStreaming(workspaceId)) {
+ this.aiService.off("stream-end", handler);
+ this.aiService.off("stream-abort", handler);
+ resolve();
+ }
+ });
+ }
+
async remove(workspaceId: string, force = false): Promise> {
// Try to remove from runtime (filesystem)
try {
@@ -697,6 +901,9 @@ export class WorkspaceService extends EventEmitter {
return Err(validation.error ?? "Invalid workspace name");
}
+ // Mark workspace as renaming to block new streams during the rename operation
+ this.renamingWorkspaces.add(workspaceId);
+
const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId);
if (!metadataResult.success) {
return Err(`Failed to get workspace metadata: ${metadataResult.error}`);
@@ -764,6 +971,9 @@ export class WorkspaceService extends EventEmitter {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Err(`Failed to rename workspace: ${message}`);
+ } finally {
+ // Always clear renaming flag, even on error
+ this.renamingWorkspaces.delete(workspaceId);
}
}
@@ -897,7 +1107,7 @@ export class WorkspaceService extends EventEmitter {
messagePreview: message.substring(0, 50),
});
- return await this.createForFirstMessage(message, options.projectPath, options);
+ return this.createForFirstMessage(message, options.projectPath, options);
}
log.debug("sendMessage handler: Received", {
@@ -908,6 +1118,15 @@ export class WorkspaceService extends EventEmitter {
});
try {
+ // Block streaming while workspace is being renamed to prevent path conflicts
+ if (this.renamingWorkspaces.has(workspaceId)) {
+ log.debug("sendMessage blocked: workspace is being renamed", { workspaceId });
+ return Err({
+ type: "unknown",
+ raw: "Workspace is being renamed. Please wait and try again.",
+ });
+ }
+
const session = this.getOrCreateSession(workspaceId);
void this.updateRecencyTimestamp(workspaceId);
@@ -950,6 +1169,15 @@ export class WorkspaceService extends EventEmitter {
options: SendMessageOptions | undefined = { model: "claude-3-5-sonnet-latest" }
): Promise> {
try {
+ // Block streaming while workspace is being renamed to prevent path conflicts
+ if (this.renamingWorkspaces.has(workspaceId)) {
+ log.debug("resumeStream blocked: workspace is being renamed", { workspaceId });
+ return Err({
+ type: "unknown",
+ raw: "Workspace is being renamed. Please wait and try again.",
+ });
+ }
+
const session = this.getOrCreateSession(workspaceId);
const result = await session.resumeStream(options);
if (!result.success) {
diff --git a/src/node/services/workspaceTitleGenerator.test.ts b/src/node/services/workspaceTitleGenerator.test.ts
new file mode 100644
index 000000000..90c3295d0
--- /dev/null
+++ b/src/node/services/workspaceTitleGenerator.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "bun:test";
+import { generatePlaceholderName } from "./workspaceTitleGenerator";
+
+describe("generatePlaceholderName", () => {
+ it("should generate a git-safe name from message", () => {
+ const result = generatePlaceholderName("Add user authentication feature");
+ expect(result).toBe("add-user-authentication-featur");
+ });
+
+ it("should handle special characters", () => {
+ const result = generatePlaceholderName("Fix bug #123 in user/profile");
+ expect(result).toBe("fix-bug-123-in-user-profile");
+ });
+
+ it("should truncate long messages", () => {
+ const result = generatePlaceholderName(
+ "This is a very long message that should be truncated to fit within the maximum length"
+ );
+ expect(result.length).toBeLessThanOrEqual(30);
+ expect(result).toBe("this-is-a-very-long-message-th");
+ });
+
+ it("should return default name for empty/whitespace input", () => {
+ expect(generatePlaceholderName("")).toBe("new-workspace");
+ expect(generatePlaceholderName(" ")).toBe("new-workspace");
+ });
+
+ it("should handle unicode characters", () => {
+ const result = generatePlaceholderName("Add émojis 🚀 and accénts");
+ expect(result).toBe("add-mojis-and-acc-nts");
+ });
+
+ it("should handle only special characters", () => {
+ const result = generatePlaceholderName("!@#$%^&*()");
+ expect(result).toBe("new-workspace");
+ });
+});
diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts
index 10cf943aa..e1fe6c522 100644
--- a/tests/runtime/runtime.test.ts
+++ b/tests/runtime/runtime.test.ts
@@ -948,7 +948,7 @@ describeIntegration("Runtime integration tests", () => {
if (!result.success) {
// Error message differs between local (git worktree) and SSH (mv command)
if (type === "local") {
- expect(result.error).toContain("Failed to move worktree");
+ expect(result.error).toContain("Failed to rename workspace");
} else {
expect(result.error).toContain("Failed to rename directory");
}