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
90 changes: 90 additions & 0 deletions apps/code/src/renderer/features/sessions/service/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2649,6 +2649,96 @@ describe("SessionService", () => {
);
});

const mockPreBootFailedSession = (overrides: Partial<AgentSession> = {}) =>
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
createMockSession({
isCloud: true,
cloudStatus: "failed",
status: "disconnected",
...overrides,
}),
);

it("refuses to resume when the previous run failed before the agent booted", async () => {
const service = getSessionService();
mockPreBootFailedSession({
cloudErrorMessage: "Sandbox could not be provisioned",
});

await expect(service.sendPrompt("task-123", "retry?")).rejects.toThrow(
"Sandbox could not be provisioned",
);
expect(mockAuthenticatedClient.runTaskInCloud).not.toHaveBeenCalled();
});

it("falls back to a generic message when the failed run has no error", async () => {
const service = getSessionService();
mockPreBootFailedSession();

await expect(service.sendPrompt("task-123", "retry?")).rejects.toThrow(
/Cloud run couldn't start/,
);
expect(mockAuthenticatedClient.runTaskInCloud).not.toHaveBeenCalled();
});

it("still resumes when a previously running agent failed mid-execution", async () => {
const service = getSessionService();
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
createMockSession({
isCloud: true,
cloudStatus: "failed",
Comment on lines +2662 to +2689
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 Prefer parameterised test for pre-boot failure cases

The two it blocks cover the same code path (pre-boot cloudStatus === "failed" with status !== "connected") and differ only in the presence of cloudErrorMessage. This is a textbook candidate for it.each, keeping the fixture and assertion logic in one place and making it easier to add further error-message variants later.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/sessions/service/service.test.ts
Line: 2662-2689

Comment:
**Prefer parameterised test for pre-boot failure cases**

The two `it` blocks cover the same code path (pre-boot `cloudStatus === "failed"` with `status !== "connected"`) and differ only in the presence of `cloudErrorMessage`. This is a textbook candidate for `it.each`, keeping the fixture and assertion logic in one place and making it easier to add further error-message variants later.

How can I resolve this? If you propose a fix, please make it concise.

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!

status: "connected",
cloudBranch: "feature/mid-run",
}),
);
mockAuthenticatedClient.getTaskRun.mockResolvedValue({
id: "run-123",
task: "task-123",
team: 123,
branch: "feature/mid-run",
runtime_adapter: "claude",
model: "claude-sonnet-4-20250514",
reasoning_effort: null,
environment: "cloud",
status: "failed",
log_url: "https://example.com/logs/run-123",
error_message: "agent crashed",
output: {},
state: {},
created_at: "2026-04-14T00:00:00Z",
updated_at: "2026-04-14T00:00:00Z",
completed_at: "2026-04-14T00:05:00Z",
});
mockAuthenticatedClient.getTask.mockResolvedValue(createMockTask());
mockAuthenticatedClient.runTaskInCloud.mockResolvedValue(
createMockTask({
latest_run: {
id: "run-456",
task: "task-123",
team: 123,
branch: "feature/mid-run",
runtime_adapter: "claude",
model: "claude-sonnet-4-20250514",
reasoning_effort: null,
environment: "cloud",
status: "queued",
log_url: "https://example.com/logs/run-456",
error_message: null,
output: {},
state: {},
created_at: "2026-04-14T00:06:00Z",
updated_at: "2026-04-14T00:06:00Z",
completed_at: null,
},
}),
);

const result = await service.sendPrompt("task-123", "try again");

expect(result.stopReason).toBe("queued");
expect(mockAuthenticatedClient.runTaskInCloud).toHaveBeenCalledTimes(1);
});

it("attempts automatic recovery on fatal error", async () => {
const service = getSessionService();
const mockSession = createMockSession({
Expand Down
9 changes: 9 additions & 0 deletions apps/code/src/renderer/features/sessions/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,15 @@ export class SessionService {
}

if (isTerminalStatus(session.cloudStatus)) {
// If the agent never booted (no `run_started`), resuming spins another
// sandbox that hits the same provisioning failure — surface the error
// instead of looping.
if (session.cloudStatus === "failed" && session.status !== "connected") {
throw new Error(
session.cloudErrorMessage ??
"Cloud run couldn't start. Check that GitHub is connected for this project, then try again.",
);
}
return this.resumeCloudRun(session, prompt);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function TaskInput({
trpcReact.folders.getMostRecentlyAccessedRepository.queryOptions(),
);
const {
lastUsedLocalWorkspaceMode,
setLastUsedLocalWorkspaceMode,
lastUsedWorkspaceMode,
setLastUsedWorkspaceMode,
Expand Down Expand Up @@ -115,7 +116,6 @@ export function TaskInput({
);

const [selectedDirectory, setSelectedDirectory] = useState("");
const workspaceMode = lastUsedWorkspaceMode || "local";
const adapter = lastUsedAdapter;
const prefillRequestKey = initialPromptKey ?? initialPrompt;

Expand Down Expand Up @@ -167,12 +167,6 @@ export function TaskInput({
}
}, [mostRecentRepo?.path, selectedDirectory]);

const setWorkspaceMode = (mode: WorkspaceMode) => {
setLastUsedWorkspaceMode(mode);
if (mode !== "cloud") {
setLastUsedLocalWorkspaceMode(mode);
}
};
const setAdapter = (newAdapter: AgentAdapter) =>
setLastUsedAdapter(newAdapter);

Expand All @@ -185,6 +179,20 @@ export function TaskInput({
refreshRepositories,
hasGithubIntegration,
} = useUserRepositoryIntegration();

// Stay optimistic while the integration list resolves to avoid flicker.
const cloudAvailable = isLoadingRepos || hasGithubIntegration;
const workspaceMode: WorkspaceMode =
!cloudAvailable && lastUsedWorkspaceMode === "cloud"
? lastUsedLocalWorkspaceMode
: lastUsedWorkspaceMode || "local";

const setWorkspaceMode = (mode: WorkspaceMode) => {
setLastUsedWorkspaceMode(mode);
if (mode !== "cloud") {
setLastUsedLocalWorkspaceMode(mode);
}
};
const {
repositories: visibleCloudRepositories,
isPending: cloudRepositoriesLoading,
Expand Down Expand Up @@ -606,6 +614,7 @@ export function TaskInput({
onChange={setWorkspaceMode}
selectedCloudEnvironmentId={selectedCloudEnvId}
onCloudEnvironmentChange={setSelectedCloudEnvId}
cloudAvailable={cloudAvailable}
size="1"
/>
{workspaceMode === "worktree" && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface WorkspaceModeSelectProps {
overrideModes?: WorkspaceMode[];
selectedCloudEnvironmentId?: string | null;
onCloudEnvironmentChange?: (envId: string | null) => void;
cloudAvailable?: boolean;
}

const LOCAL_MODES: {
Expand Down Expand Up @@ -67,6 +68,7 @@ export function WorkspaceModeSelect({
overrideModes,
selectedCloudEnvironmentId,
onCloudEnvironmentChange,
cloudAvailable = true,
}: WorkspaceModeSelectProps) {
const cloudModeEnabled =
useFeatureFlag("twig-cloud-mode-toggle") || import.meta.env.DEV;
Expand All @@ -80,9 +82,9 @@ export function WorkspaceModeSelect({
openSettings("cloud-environments", "create");
}, [openSettings]);

const showCloud = overrideModes
? overrideModes.includes("cloud")
: cloudModeEnabled;
const showCloud =
cloudAvailable &&
(overrideModes ? overrideModes.includes("cloud") : cloudModeEnabled);

const localModes = useMemo(
() =>
Expand Down
Loading