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
10 changes: 4 additions & 6 deletions apps/cli/src/__tests__/command-output/thread-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,7 @@ describe("bb thread spawn command output", () => {
const helpOutput = await getHelpOutput(["thread", "spawn"], register);
expect(helpOutput).toContain("--permission-mode <mode>");
expect(helpOutput).toContain("--visibility <visibility>");
expect(helpOutput).toMatch(
/Permission mode: accept-edits, auto, or full/,
);
expect(helpOutput).toMatch(/Permission mode: accept-edits, auto, or full/);
});

it("bb thread spawn reports invalid permission mode choices", async () => {
Expand Down Expand Up @@ -393,11 +391,11 @@ describe("bb thread spawn command output", () => {
).toEqual(thread);
});

it("bb thread spawn prefixes missing-project-default failures with context", async () => {
it("bb thread spawn prefixes model-catalog failures with context", async () => {
vi.stubEnv("BB_PROJECT_ID", "proj-1");
const post = vi.fn(async () => {
throw new Error(
"HTTP 400: Provider is required when project proj-1 has no stored execution defaults for thread type standard",
"HTTP 503: Unable to load codex models to resolve the default",
);
});
stubServerApi({ "v1.threads.$post": post });
Expand All @@ -410,7 +408,7 @@ describe("bb thread spawn command output", () => {
).rejects.toThrow("process.exit:1");

expect(collectLogLines(vi.mocked(console.error))).toContain(
"Error: Failed to create thread: HTTP 400: Provider is required when project proj-1 has no stored execution defaults for thread type standard",
"Error: Failed to create thread: HTTP 503: Unable to load codex models to resolve the default",
);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/thread/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function registerSpawnCommand(
parent
.command("spawn")
.description(
"Spawn a new thread for a project; omitted provider and execution flags inherit remembered project defaults",
"Spawn a new thread; omitted execution flags use remembered project defaults, then the target provider catalog default",
)
.requiredOption("--prompt <prompt>", "Initial prompt for the thread")
.option("--json", "Print machine-readable JSON output")
Expand Down
20 changes: 1 addition & 19 deletions apps/server/src/services/projects/personal-project.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,6 @@
import {
ensurePersonalProject,
getProjectExecutionDefaults,
upsertProjectExecutionDefaults,
} from "@bb/db";
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import { ensurePersonalProject } from "@bb/db";
import type { DbConnection } from "@bb/db";
import { buildInitialProjectExecutionDefaults } from "../threads/thread-default-policy.js";

export function ensurePersonalProjectBootstrap(db: DbConnection): void {
ensurePersonalProject(db);

const existingDefaults = getProjectExecutionDefaults(db, {
projectId: PERSONAL_PROJECT_ID,
});
if (existingDefaults) {
return;
}

upsertProjectExecutionDefaults(db, {
projectId: PERSONAL_PROJECT_ID,
...buildInitialProjectExecutionDefaults(),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ message agents, or inspect projects, providers, and environments.

- Use `bb thread spawn --project <project-id> --prompt "..."` to create another
thread. Pass the intended project explicitly; the CLI does not infer it from
context variables.
context variables. Omitted execution flags use remembered project defaults;
without a remembered model, bb uses the explicitly requested provider or
Codex and resolves its provider-reported default model on the target machine.
- Add repeatable `--file <path>` / `--image <path>` flags for structured prompt
attachments, and `--section <id>` to add the new thread to a section. These
flags pass host-readable absolute paths (or relative server-upload tokens)
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/services/system/execution-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ interface BuildModelLoadErrorArgs {
provider: ProviderInfo;
}

export interface ResolveSystemProviderModelsArgs {
hostId: string;
providerId: string;
}

interface ExpectedFallbackErrorLogFields {
errorCode: string;
errorDetails?: unknown;
Expand Down Expand Up @@ -269,6 +274,51 @@ function findCustomAcpAgentForProviderId(
);
}

/**
* Load one provider's model catalog on an already-resolved host. Unlike the
* full execution-options response, this does not probe for other installed ACP
* agents, so thread creation can resolve an omitted model with one targeted
* daemon request.
*/
export async function resolveSystemProviderModels(
deps: LoggedWorkSessionDeps,
args: ResolveSystemProviderModelsArgs,
): Promise<ModelListResult> {
const configuredProvider = listConfiguredSystemProviderInfos(
deps.config.customAcpAgents,
[],
).find((provider) => provider.id === args.providerId);
const knownAcpAgent = findKnownAcpAgentForProviderId(args.providerId);
const provider =
configuredProvider ??
(knownAcpAgent === undefined
? undefined
: buildKnownAcpProviderInfo(knownAcpAgent));
if (provider === undefined) {
throw new ApiError(
400,
"invalid_request",
`Unsupported provider ${args.providerId}`,
);
}

const result = await loadSystemProviderModels(deps, {
hostId: args.hostId,
provider,
});
const { models, selectedOnlyModels } = appendCustomModels({
customModels: deps.config.customModels,
models: result.models,
providerId: provider.id,
selectedOnlyModels: result.selectedOnlyModels,
});
return {
models,
selectedOnlyModels,
modelLoadError: result.modelLoadError,
};
}

function buildCustomModel(customModel: CustomProviderModel): AvailableModel {
return {
id: customModel.model,
Expand Down
11 changes: 2 additions & 9 deletions apps/server/src/services/threads/project-execution-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
ResolvedThreadExecutionOptions,
} from "@bb/domain";
import type { AppDeps } from "../../types.js";
import { ApiError } from "../../errors.js";
import type {
ThreadCreateServiceRequest,
ThreadCreateServiceRequestInput,
Expand All @@ -29,6 +28,7 @@ export interface ResolveProjectExecutionDefaultsForCreateArgs {
export interface ResolvedProjectExecutionDefaultsForCreate {
executionDefaults: ProjectExecutionDefaults | null;
providerId: string;
requestedModel: string | null;
}

type CreateExecutionInputSources =
Expand Down Expand Up @@ -95,17 +95,10 @@ export function resolveProjectExecutionDefaultsForCreate(
});
const { executionDefaults, providerId } = resolution;

if (!requestedModel && !executionDefaults) {
throw new ApiError(
400,
"invalid_request",
`Model is required when project ${args.projectId} has no stored execution defaults for provider ${providerId}`,
);
}

return {
executionDefaults,
providerId,
requestedModel: requestedModel ?? null,
};
}

Expand Down
70 changes: 68 additions & 2 deletions apps/server/src/services/threads/thread-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
hasNonTerminalThreadInEnvironment,
} from "@bb/db";
import type {
ProjectExecutionDefaults,
Project,
Thread,
ThreadOriginKind,
Expand Down Expand Up @@ -43,6 +44,7 @@ import {
type ResolvedStableThreadRequestEnvironment,
} from "./thread-request-eligibility.js";
import {
buildProviderThreadExecutionDefaults,
resolveCreateThreadEnvironment,
resolveProjectDefaultThreadEnvironment,
} from "./thread-default-policy.js";
Expand All @@ -63,6 +65,7 @@ import type {
} from "./thread-provisioning-context.js";
import { resolveManagedDefaultBaseBranchSpec } from "../projects/worktree-base-branch.js";
import { applyLoggedEnvironmentLifecycleEvent } from "../environments/lifecycle-outcome.js";
import { resolveSystemProviderModels } from "../system/execution-options.js";

type ThreadCreateDeps = LoggedPendingInteractionWorkSessionDeps;

Expand Down Expand Up @@ -104,6 +107,60 @@ interface DeriveThreadCreateTitleFallbackArgs {
sourceThread: Thread | null;
}

interface ResolveCatalogExecutionDefaultsArgs {
executionDefaults: ProjectExecutionDefaults | null;
hostId: string | null;
providerId: string;
requestedModel: string | null;
}

async function resolveCatalogExecutionDefaults(
deps: ThreadCreateDeps,
args: ResolveCatalogExecutionDefaultsArgs,
): Promise<ProjectExecutionDefaults | null> {
if (args.executionDefaults !== null || args.requestedModel !== null) {
return args.executionDefaults;
}
if (args.hostId === null) {
throw new ApiError(
502,
"host_unavailable",
`Cannot resolve the default ${args.providerId} model without an execution host`,
true,
);
}

const catalog = await resolveSystemProviderModels(deps, {
hostId: args.hostId,
providerId: args.providerId,
});
if (catalog.modelLoadError !== null) {
throw new ApiError(
503,
"model_catalog_unavailable",
`Unable to load ${args.providerId} models to resolve the default. Try again once the host is connected and the provider is ready.`,
{
details: catalog.modelLoadError,
retryable: true,
},
);
}
const defaultModel =
catalog.models.find((model) => model.isDefault) ?? catalog.models[0];
if (defaultModel === undefined) {
throw new ApiError(
503,
"model_catalog_unavailable",
`The ${args.providerId} model catalog is empty, so no default model can be resolved.`,
true,
);
}
return buildProviderThreadExecutionDefaults({
providerId: args.providerId,
model: defaultModel.model,
});
}

/**
* Resolve the native-fork descriptor for a source-derived thread, or null when
* it cannot be provisioned as a fork. Both forks and side chats are native
Expand Down Expand Up @@ -624,7 +681,7 @@ export async function createThreadFromRequest(
input: requestInput.input,
projectId: requestInput.projectId,
});
const { executionDefaults, providerId } =
const { executionDefaults, providerId, requestedModel } =
resolveProjectExecutionDefaultsForCreate(deps, {
executionInputSources: requestInput.executionInputSources,
model: requestInput.model,
Expand Down Expand Up @@ -667,6 +724,15 @@ export async function createThreadFromRequest(
projectId: request.projectId,
});
await ensureCreateHostOnline(deps, { resolvedEnvironment });
const resolvedExecutionDefaults = await resolveCatalogExecutionDefaults(
deps,
{
executionDefaults,
hostId: childHostIdForResolvedEnvironment(resolvedEnvironment),
providerId,
requestedModel,
},
);

let environmentId: string | null = null;
let environmentIntent: ThreadProvisionEnvironmentIntent;
Expand Down Expand Up @@ -785,7 +851,7 @@ export async function createThreadFromRequest(
const thread = await createProvisioningThread(deps, {
environmentId,
environmentIntent,
executionDefaults,
executionDefaults: resolvedExecutionDefaults,
fork,
...(options.providerInput !== undefined
? { providerInput: options.providerInput }
Expand Down
21 changes: 8 additions & 13 deletions apps/server/src/services/threads/thread-default-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export function resolveWorkflowsEnabledPolicy(providerId: string): boolean {
}
const DEFAULT_PERMISSION_MODE: PermissionMode = "auto";
const PRODUCT_DEFAULT_PROVIDER_ID = "codex";
const PRODUCT_DEFAULT_MODEL = "gpt-5.5";

export interface ResolveCreateThreadExecutionDefaultsArgs {
requestedProviderId?: string;
Expand Down Expand Up @@ -154,13 +153,6 @@ function resolveSupportedPermissionMode(
return supportedPermissionModes[0] ?? DEFAULT_PERMISSION_MODE;
}

function buildProductThreadExecutionDefaults(
providerId: string,
): ProjectExecutionDefaults | null {
const defaults = buildInitialProjectExecutionDefaults();
return defaults.providerId === providerId ? defaults : null;
}

export function resolveCreateThreadExecutionDefaults(
args: ResolveCreateThreadExecutionDefaultsArgs,
): CreateThreadExecutionDefaultsResolved {
Expand All @@ -179,18 +171,21 @@ export function resolveCreateThreadExecutionDefaults(
}

return {
executionDefaults: buildProductThreadExecutionDefaults(providerId),
executionDefaults: null,
providerId,
};
}

export function buildInitialProjectExecutionDefaults(): ProjectExecutionDefaults {
export function buildProviderThreadExecutionDefaults(args: {
model: string;
providerId: string;
}): ProjectExecutionDefaults {
return {
providerId: PRODUCT_DEFAULT_PROVIDER_ID,
model: PRODUCT_DEFAULT_MODEL,
providerId: args.providerId,
model: args.model,
reasoningLevel: DEFAULT_REASONING_LEVEL,
permissionMode: resolveSupportedPermissionMode({
providerId: PRODUCT_DEFAULT_PROVIDER_ID,
providerId: args.providerId,
preferredPermissionMode: DEFAULT_PERMISSION_MODE,
}),
serviceTier: DEFAULT_SERVICE_TIER,
Expand Down
10 changes: 2 additions & 8 deletions apps/server/test/app/skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe("server skeleton", () => {
db.$client.close();
});

it("ensures the personal project and execution defaults on startup", () => {
it("ensures the personal project without pinning execution defaults", () => {
const db = initDb(":memory:");
try {
expect(getPersonalProject(db)).toMatchObject({
Expand All @@ -241,13 +241,7 @@ describe("server skeleton", () => {
getProjectExecutionDefaults(db, {
projectId: PERSONAL_PROJECT_ID,
}),
).toMatchObject({
model: expect.any(String),
permissionMode: expect.any(String),
providerId: expect.any(String),
reasoningLevel: expect.any(String),
serviceTier: expect.any(String),
});
).toBeNull();
} finally {
db.$client.close();
}
Expand Down
Loading
Loading