Skip to content
Merged
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
7 changes: 6 additions & 1 deletion backend/internal/adapters/workspace/gitworktree/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ func worktreeListPorcelainArgs(repo string) []string {
func baseRefCandidates(branch, defaultBranch string) []string {
candidates := []string{"origin/" + branch}
if strings.Contains(defaultBranch, "/") {
// A qualified default ("upstream/main") is used verbatim; git's refname
// disambiguation already falls back to refs/heads/<defaultBranch>.
candidates = append(candidates, defaultBranch)
} else {
candidates = append(candidates, "origin/"+defaultBranch)
// The local head comes after origin/<defaultBranch> so remote-tracking
// still wins when present, but a remoteless repo can base new branches
// on its local default branch instead of failing BRANCH_NOT_FETCHED.
candidates = append(candidates, "origin/"+defaultBranch, "refs/heads/"+defaultBranch)
}
return append(candidates, branch)
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,42 @@ func TestWorkspaceIntegrationDestroyDirtyWorktree(t *testing.T) {
}
}

// TestWorkspaceIntegrationCreateInRemotelessRepo guards the BRANCH_NOT_FETCHED
// regression: a repo with no remote configured must still spawn worktrees for
// new branches by basing them on the local default-branch head
// (refs/heads/main) once no origin/* candidate resolves.
func TestWorkspaceIntegrationCreateInRemotelessRepo(t *testing.T) {
git := requireGit(t)
tmp := t.TempDir()
repo := filepath.Join(tmp, "repo")
run(t, git, "init", repo)
runGit(t, git, repo, "config", "user.email", "ao@example.com")
runGit(t, git, repo, "config", "user.name", "Ao Agents")
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("seed\n"), 0o644); err != nil {
t.Fatalf("write seed: %v", err)
}
runGit(t, git, repo, "add", "README.md")
runGit(t, git, repo, "commit", "-m", "seed")
runGit(t, git, repo, "branch", "-M", "main")

root := filepath.Join(tmp, "managed")
ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}})
if err != nil {
t.Fatalf("new: %v", err)
}
ctx := context.Background()
info, err := ws.Create(ctx, ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/remoteless"})
if err != nil {
t.Fatalf("create in remoteless repo: %v", err)
}
if _, err := os.Stat(filepath.Join(info.Path, "README.md")); err != nil {
t.Fatalf("created worktree missing seed file: %v", err)
}
if err := ws.Destroy(ctx, info); err != nil {
t.Fatalf("destroy: %v", err)
}
}

func requireGit(t *testing.T) string {
t.Helper()
git, err := exec.LookPath("git")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestCommandArgs(t *testing.T) {

func TestBaseRefCandidates(t *testing.T) {
got := baseRefCandidates("feature/test", "main")
want := []string{"origin/feature/test", "origin/main", "feature/test"}
want := []string{"origin/feature/test", "origin/main", "refs/heads/main", "feature/test"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("candidates = %#v, want %#v", got, want)
}
Expand Down
76 changes: 72 additions & 4 deletions frontend/src/renderer/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { TooltipProvider } from "./components/ui/tooltip";
import { useUiStore } from "./stores/ui-store";

const { postMock, mockData } = vi.hoisted(() => ({
const { postMock, patchMock, mockData } = vi.hoisted(() => ({
postMock: vi.fn(),
patchMock: vi.fn(),
mockData: {
projectsError: undefined as Error | undefined,
projects: [] as { id: string; name: string; path: string; sessionPrefix: string }[],
Expand Down Expand Up @@ -38,6 +40,7 @@ vi.mock("./lib/api-client", () => ({
return { data: undefined, error: new Error(`unexpected GET ${url}`) };
}),
POST: postMock,
PATCH: patchMock,
},
}));

Expand All @@ -47,15 +50,19 @@ vi.mock("./components/TerminalPane", () => ({

function renderApp() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// TooltipProvider mirrors routes/__root.tsx, which wraps App in production.
return render(
<QueryClientProvider client={queryClient}>
<App />
<TooltipProvider>
<App />
</TooltipProvider>
</QueryClientProvider>,
);
}

beforeEach(() => {
postMock.mockReset();
patchMock.mockReset();
mockData.projectsError = undefined;
mockData.projects = [];
mockData.sessions = [];
Expand Down Expand Up @@ -158,15 +165,51 @@ test("spawns a worker from the New worker modal", async () => {
await user.type(await screen.findByLabelText("Prompt"), "Make task creation work");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));

// No `branch` field: it names the worktree branch, and sending the default
// base ("main") makes the daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE.
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "claude-code",
prompt: "Make task creation work",
branch: "main",
},
});
// No worker name given, so no rename round-trip.
expect(patchMock).not.toHaveBeenCalled();
});

test("renames the spawned worker when a name is given", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
postMock.mockResolvedValueOnce({
data: {
session: {
id: "new-task",
projectId: "proj-1",
harness: "claude-code",
isTerminated: false,
},
},
});
patchMock.mockResolvedValueOnce({
data: { ok: true, sessionId: "new-task", displayName: "fix-login" },
});

renderApp();

await screen.findByRole("button", { name: "Select my-app" });

await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Worker name"), "fix-login");
await user.type(screen.getByLabelText("Prompt"), "Fix the login bug");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));

expect(patchMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}", {
params: { path: { sessionId: "new-task" } },
body: { displayName: "fix-login" },
});
expect(await screen.findByRole("button", { name: "fix-login" })).toBeInTheDocument();
});
Comment on lines +182 to 213

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.

P2 Rename-failure path is untested

The new "renames the spawned worker when a name is given" test covers the happy path (PATCH succeeds, displayName returned). There is no test for the case where patchMock resolves with { error: ... } or { data: undefined } — i.e., the best-effort silent fallback described in the App.tsx comment. Without a test, it's easy for a future refactor to accidentally surface the rename error to the user (breaking the spawn) or show the wrong title without anyone noticing.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


test("surfaces an error when spawning fails", async () => {
Expand All @@ -188,8 +231,33 @@ test("surfaces an error when spawning fails", async () => {
kind: "worker",
harness: "claude-code",
prompt: "Failing task",
branch: "main",
},
});
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
});

test("surfaces the daemon error envelope message, not [object Object]", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
// openapi-fetch resolves non-2xx bodies as a plain APIError envelope.
postMock.mockResolvedValueOnce({
error: {
code: "BRANCH_CHECKED_OUT_ELSEWHERE",
error: "Conflict",
message: "main is checked out at /home/me/my-app",
},
});

renderApp();

await screen.findByRole("button", { name: "Select my-app" });

await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Prompt"), "Failing task");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));

expect(
await screen.findByText("main is checked out at /home/me/my-app (BRANCH_CHECKED_OUT_ELSEWHERE)"),
).toBeInTheDocument();
expect(screen.queryByText("[object Object]")).not.toBeInTheDocument();
});
44 changes: 38 additions & 6 deletions frontend/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Could not load projects";
}

/**
* openapi-fetch resolves non-2xx responses to a plain APIError envelope
* ({ code, error, message, ... }), not an Error — String() on it renders
* "[object Object]".
*/
function apiErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) return error.message;
if (typeof error === "string" && error) return error;
if (error && typeof error === "object") {
const envelope = error as { message?: unknown; code?: unknown };
if (typeof envelope.message === "string" && envelope.message) {
return typeof envelope.code === "string" && envelope.code
? `${envelope.message} (${envelope.code})`
: envelope.message;
}
}
return fallback;
}

export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
const queryClient = useQueryClient();
const {
Expand Down Expand Up @@ -106,7 +125,7 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
const createProject = async (input: { path: string }) => {
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });

if (error) throw error;
if (error) throw new Error(apiErrorMessage(error, "Could not add project"));
if (!data?.project) throw new Error("Project creation returned no project");

const workspace: WorkspaceSummary = {
Expand All @@ -121,23 +140,36 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
selectWorkspace(workspace.id);
};

const createTask = async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => {
const createTask = async (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => {
// No `branch` here: the API's branch field names the session's worktree
// branch (sending a checked-out branch like "main" 409s); the base branch
// comes from the project config.
const { data, error } = await apiClient.POST("/api/v1/sessions", {
body: {
projectId: input.projectId,
kind: "worker",
harness: input.harness,
prompt: input.prompt,
branch: input.branch || undefined,
},
});

if (error || !data?.session) {
throw new Error(error instanceof Error ? error.message : error ? String(error) : "No session returned");
throw new Error(apiErrorMessage(error, "No session returned"));
}

const session = data.session;

// Best-effort: the session is already running, so a failed rename should
// not surface as a failed spawn (retrying would create a duplicate).
let displayName: string | undefined;
if (input.name) {
const renamed = await apiClient.PATCH("/api/v1/sessions/{sessionId}", {
params: { path: { sessionId: session.id } },
body: { displayName: input.name },
});
displayName = renamed.data?.displayName;
Comment on lines +162 to +170

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.

P2 Rename errors are silently swallowed with no user feedback

When the PATCH rename fails (network error, 4xx/5xx from the daemon), renamed.data?.displayName is undefined, displayName stays undefined, and the session title silently falls back to the prompt text. The user sees the modal close and the session appear, but with a different name than they typed — there's no indication the rename failed. The comment says this is intentional, but at minimum a console.warn would help diagnose the situation. If the UX contract is "rename failure = silently use the prompt as title", a brief inline note in the UI (or at least a console.warn) would make the silent fallback discoverable.

}

updateWorkspaces((current) =>
current.map((item) =>
item.id === input.projectId
Expand All @@ -149,9 +181,9 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
terminalHandleId: session.terminalHandleId,
workspaceId: item.id,
workspaceName: item.name,
title: input.prompt,
title: displayName ?? input.prompt,
provider: toAgentProvider(session.harness),
branch: input.branch ?? "",
branch: "",
status: toSessionStatus(session.status, session.isTerminated),
updatedAt: "now",
},
Expand Down
36 changes: 11 additions & 25 deletions frontend/src/renderer/components/SpawnWorkerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ type SpawnWorkerModalProps = {
onOpenChange: (open: boolean) => void;
workspaces: WorkspaceSummary[];
defaultProjectId?: string;
onCreateTask: (input: {
projectId: string;
prompt: string;
branch?: string;
harness?: AgentProvider;
}) => Promise<void>;
onCreateTask: (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => Promise<void>;
};

export function SpawnWorkerModal({
Expand All @@ -47,7 +42,6 @@ export function SpawnWorkerModal({
const [projectId, setProjectId] = useState(fallbackProjectId);
const [agent, setAgent] = useState<AgentProvider>("claude-code");
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
const [branch, setBranch] = useState("main");
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
const [prompt, setPrompt] = useState("");
const [error, setError] = useState<string | null>(null);
Expand All @@ -62,9 +56,6 @@ export function SpawnWorkerModal({
}, [open, fallbackProjectId]);

const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
const branchOptions = Array.from(
new Set(["main", ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]),
);
const nameValid = name === "" || NAME_RULE.test(name);
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;

Expand All @@ -77,12 +68,11 @@ export function SpawnWorkerModal({
await onCreateTask({
projectId,
prompt: prompt.trim(),
branch: basedOn === "Branch" ? branch.trim() || undefined : undefined,
name: name || undefined,
harness: agent,
});
setName("");
setPrompt("");
setBranch("main");
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not spawn worker");
Expand Down Expand Up @@ -169,19 +159,15 @@ export function SpawnWorkerModal({
</div>
</div>
<div className="p-2.5">
{basedOn === "Branch" ? (
<SelectControl
aria-label="Based on branch"
className="flex w-full"
onChange={setBranch}
value={branch}
options={branchOptions.map((option) => ({ value: option, label: option }))}
/>
) : (
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
</p>
)}
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{/* The API has no per-spawn base branch — the worker branches off the
project's configured default branch in a fresh worktree. */}
{basedOn === "Branch"
? "Branches off the project's default branch in a fresh worktree."
: basedOn === "Issue"
? "Pick an issue to start from."
: "Pick a pull request to start from."}
</p>
</div>
</div>

Expand Down
Loading
Loading