From bcfc35710bc166bf9e32f7036a34c4fc18dc1cd2 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 11 Aug 2026 09:52:37 -0700 Subject: [PATCH 1/5] Fix transient thread title inference failures --- .../src/codex-chatgpt-client.test.ts | 94 ++++++++++++++- apps/host-daemon/src/codex-chatgpt-client.ts | 110 +++++++++++++----- apps/server/src/services/ai/inference.ts | 6 +- .../src/services/ai/voice-transcription.ts | 5 +- .../threads/thread-metadata-inference.ts | 7 +- .../src/services/threads/title-generation.ts | 50 +++++--- apps/server/test/ai/inference.test.ts | 28 +++++ .../threads/generated-branch-names.test.ts | 42 ++++++- packages/host-daemon-contract/src/commands.ts | 2 +- .../test/contract.test.ts | 10 +- 10 files changed, 296 insertions(+), 58 deletions(-) diff --git a/apps/host-daemon/src/codex-chatgpt-client.test.ts b/apps/host-daemon/src/codex-chatgpt-client.test.ts index 0f2d777cef..ab0344ebb3 100644 --- a/apps/host-daemon/src/codex-chatgpt-client.test.ts +++ b/apps/host-daemon/src/codex-chatgpt-client.test.ts @@ -128,6 +128,32 @@ function stalledSseResponse(): Response { }); } +function delayedSseResponse(delayMs: number, events: JsonValue[]): Response { + const bytes = new TextEncoder().encode( + `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\ndata: [DONE]\n\n`, + ); + return new Response( + new ReadableStream({ + start(controller) { + setTimeout(() => { + try { + controller.enqueue(bytes); + controller.close(); + } catch { + // The request deadline can cancel the reader before this fires. + } + }, delayMs); + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ); +} + function requiredFetchCall(fetchMock: FetchMock, index: number) { const call = fetchMock.mock.calls[index]; if (!call) { @@ -315,6 +341,41 @@ describe("Codex ChatGPT client", () => { }); }); + it("classifies streamed overload failures as service unavailable", async () => { + const homeDir = await makeTempHome(); + await writeCodexApiKeyAuth({ + homeDir, + apiKey: "sk-codex-api-key", + }); + const fetchMock = setupFetchMock(); + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: "response.failed", + response: { + error: { + message: + "Our servers are currently overloaded. Please try again later.", + }, + }, + }, + ]), + ); + + await expect( + completeCodexInference({ + type: "codex.inference.complete", + model: "gpt-5.6-luna", + reasoningEffort: "none", + prompt: "Return a title", + outputSchema: { type: "object" }, + timeoutMs: 10_000, + }), + ).rejects.toMatchObject({ + code: "codex_service_unavailable", + }); + }); + it("uses Codex auth read-only without refreshing expired-looking access tokens", async () => { const homeDir = await makeTempHome(); const oldAccessToken = createAccessToken({ @@ -414,6 +475,37 @@ describe("Codex ChatGPT client", () => { }); }); + it("uses one deadline across response headers and SSE body reads", async () => { + const homeDir = await makeTempHome(); + await writeCodexApiKeyAuth({ + homeDir, + apiKey: "sk-codex-api-key", + }); + const fetchMock = setupFetchMock(); + fetchMock.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + return delayedSseResponse(40, [ + { + type: "response.output_text.delta", + delta: '{"title":"Too late"}', + }, + ]); + }); + + await expect( + completeCodexInference({ + type: "codex.inference.complete", + model: "gpt-5.6-luna", + reasoningEffort: "none", + prompt: "Return a title", + outputSchema: { type: "object" }, + timeoutMs: 60, + }), + ).rejects.toMatchObject({ + code: "codex_request_timeout", + }); + }); + it("caps oversized Codex error response bodies", async () => { const homeDir = await makeTempHome(); await writeCodexApiKeyAuth({ @@ -445,7 +537,7 @@ describe("Codex ChatGPT client", () => { } expect(thrown).toMatchObject({ - code: "codex_request_failed", + code: "codex_service_unavailable", }); expect(thrown?.message.length).toBeLessThan(700); }); diff --git a/apps/host-daemon/src/codex-chatgpt-client.ts b/apps/host-daemon/src/codex-chatgpt-client.ts index 27801f9796..989d912958 100644 --- a/apps/host-daemon/src/codex-chatgpt-client.ts +++ b/apps/host-daemon/src/codex-chatgpt-client.ts @@ -37,25 +37,30 @@ type ReadOverflowBehavior = "throw" | "truncate"; type CodexRequestOperation = "inference" | "transcription"; interface TimeoutFetchArgs { - timeoutMs: number; + deadline: CodexRequestDeadline; work: (signal: AbortSignal) => Promise; } +interface CodexRequestDeadline { + expiresAt: number; + timeoutMs: number; +} + interface ReadChunkWithTimeoutArgs { - readTimeoutMs: number; + deadline: CodexRequestDeadline; reader: ReadableStreamDefaultReader; } interface ReadLimitedResponseTextArgs { + deadline: CodexRequestDeadline; maxBytes: number; overflowBehavior: ReadOverflowBehavior; - readTimeoutMs: number; } interface ReadResponseTextFromSseArgs { + deadline: CodexRequestDeadline; maxBytes: number; maxEventChars: number; - readTimeoutMs: number; } interface ChatGptFetchArgs { @@ -66,40 +71,46 @@ interface ChatGptFetchArgs { interface ResponsesFetchArgs { auth: CodexAuthCredentials; command: InferenceCompleteCommand; + deadline: CodexRequestDeadline; request: CodexResponsesRequest; } interface ChatGptResponsesFetchArgs { auth: CodexChatGptAuthCredentials; command: InferenceCompleteCommand; + deadline: CodexRequestDeadline; request: CodexResponsesRequest; } interface OpenAiResponsesFetchArgs { auth: CodexOpenAiApiKeyCredentials; command: InferenceCompleteCommand; + deadline: CodexRequestDeadline; request: CodexResponsesRequest; } interface TranscriptionFetchArgs { auth: CodexAuthCredentials; command: VoiceTranscribeCommand; + deadline: CodexRequestDeadline; } interface ChatGptTranscriptionFetchArgs { auth: CodexChatGptAuthCredentials; command: VoiceTranscribeCommand; + deadline: CodexRequestDeadline; } interface OpenAiTranscriptionFetchArgs { auth: CodexOpenAiApiKeyCredentials; command: VoiceTranscribeCommand; + deadline: CodexRequestDeadline; } interface CodexHttpErrorArgs { + deadline: CodexRequestDeadline; operation: CodexRequestOperation; response: Response; - readTimeoutMs: number; } interface CodexResponseFormat { @@ -192,17 +203,35 @@ function createOpenAiResponsesHeaders( return headers; } +function createCodexRequestDeadline(timeoutMs: number): CodexRequestDeadline { + return { + expiresAt: performance.now() + timeoutMs, + timeoutMs, + }; +} + +function remainingCodexRequestTimeoutMs( + deadline: CodexRequestDeadline, +): number { + const remainingMs = Math.ceil(deadline.expiresAt - performance.now()); + if (remainingMs <= 0) { + throw codexRequestTimeoutError(deadline.timeoutMs); + } + return remainingMs; +} + async function runWithTimeout(args: TimeoutFetchArgs): Promise { const abortController = new AbortController(); + const timeoutMs = remainingCodexRequestTimeoutMs(args.deadline); const timeout = setTimeout(() => { abortController.abort(); - }, args.timeoutMs); + }, timeoutMs); timeout.unref(); try { return await args.work(abortController.signal); } catch (error) { if (abortController.signal.aborted) { - throw codexRequestTimeoutError(args.timeoutMs); + throw codexRequestTimeoutError(args.deadline.timeoutMs); } throw error; } finally { @@ -227,19 +256,20 @@ function codexResponseTooLargeError(): ExpectedCommandDispatchError { } async function readChunkWithTimeout({ + deadline, reader, - readTimeoutMs, }: ReadChunkWithTimeoutArgs): ReturnType< ReadableStreamDefaultReader["read"] > { let timeout: ReturnType | null = null; + const timeoutMs = remainingCodexRequestTimeoutMs(deadline); try { return await Promise.race([ reader.read(), new Promise((_, reject) => { timeout = setTimeout(() => { - reject(codexRequestTimeoutError(readTimeoutMs)); - }, readTimeoutMs); + reject(codexRequestTimeoutError(deadline.timeoutMs)); + }, timeoutMs); timeout.unref(); }), ]); @@ -277,8 +307,8 @@ async function readLimitedResponseText( try { while (true) { const chunk = await readChunkWithTimeout({ + deadline: args.deadline, reader, - readTimeoutMs: args.readTimeoutMs, }); if (chunk.done) { break; @@ -293,9 +323,11 @@ async function readLimitedResponseText( } const allowedBytes = value.byteLength - (totalBytes - args.maxBytes); if (allowedBytes > 0) { - chunks.push(decoder.decode(value.slice(0, allowedBytes), { - stream: true, - })); + chunks.push( + decoder.decode(value.slice(0, allowedBytes), { + stream: true, + }), + ); } truncated = true; await cancelReaderBestEffort(reader); @@ -334,12 +366,12 @@ async function fetchChatGpt(args: ChatGptFetchArgs): Promise { async function readErrorText( response: Response, - readTimeoutMs: number, + deadline: CodexRequestDeadline, ): Promise { const text = await readLimitedResponseText(response, { + deadline, maxBytes: CODEX_ERROR_TEXT_MAX_BYTES, overflowBehavior: "truncate", - readTimeoutMs, }).catch(() => ""); return text.length > 400 ? `${text.slice(0, 400)}...` : text; } @@ -351,9 +383,21 @@ function codexRequestErrorCode(status: number): string { if (status === 429) { return "codex_rate_limited"; } + if (status >= 500) { + return "codex_service_unavailable"; + } return "codex_request_failed"; } +const CODEX_SERVICE_UNAVAILABLE_PATTERN = + /\b(?:overloaded|temporarily unavailable|try again later)\b/iu; + +function codexStreamFailureErrorCode(message: string): string { + return CODEX_SERVICE_UNAVAILABLE_PATTERN.test(message) + ? "codex_service_unavailable" + : "codex_request_failed"; +} + function extractJsonErrorMessage(value: JsonValue): string | null { if (typeof value === "string") { const normalized = value.replace(/\s+/g, " ").trim(); @@ -402,12 +446,12 @@ function extractProviderErrorMessage(rawText: string): string | null { } async function createCodexHttpError({ + deadline, operation, response, - readTimeoutMs, }: CodexHttpErrorArgs): Promise { const providerMessage = extractProviderErrorMessage( - await readErrorText(response, readTimeoutMs), + await readErrorText(response, deadline), ); const details = providerMessage ? `: ${providerMessage}` : ""; return new ExpectedCommandDispatchError( @@ -530,8 +574,8 @@ async function readResponseTextFromSse( try { while (true) { const chunk = await readChunkWithTimeout({ + deadline: args.deadline, reader, - readTimeoutMs: args.readTimeoutMs, }); if (chunk.done) { break; @@ -591,7 +635,7 @@ async function readResponseTextFromSse( if (failedMessage) { throw new ExpectedCommandDispatchError( - "codex_request_failed", + codexStreamFailureErrorCode(failedMessage), failedMessage, ); } @@ -713,7 +757,7 @@ async function fetchChatGptResponses( args: ChatGptResponsesFetchArgs, ): Promise { return runWithTimeout({ - timeoutMs: args.command.timeoutMs, + deadline: args.deadline, work: (signal) => fetchChatGpt({ url: CODEX_RESPONSES_URL, @@ -731,7 +775,7 @@ async function fetchOpenAiResponses( args: OpenAiResponsesFetchArgs, ): Promise { return runWithTimeout({ - timeoutMs: args.command.timeoutMs, + deadline: args.deadline, work: (signal) => fetch(OPENAI_RESPONSES_URL, { method: "POST", @@ -747,11 +791,13 @@ async function fetchResponses(args: ResponsesFetchArgs): Promise { ? fetchChatGptResponses({ auth: args.auth, command: args.command, + deadline: args.deadline, request: args.request, }) : fetchOpenAiResponses({ auth: args.auth, command: args.command, + deadline: args.deadline, request: args.request, }); } @@ -759,22 +805,23 @@ async function fetchResponses(args: ResponsesFetchArgs): Promise { export async function completeCodexInference( command: InferenceCompleteCommand, ): Promise> { + const deadline = createCodexRequestDeadline(command.timeoutMs); const auth = await readCodexAuthCredentials(); const request = buildCodexResponsesRequest(command); - const response = await fetchResponses({ auth, command, request }); + const response = await fetchResponses({ auth, command, deadline, request }); if (!response.ok) { throw await createCodexHttpError({ + deadline, operation: "inference", response, - readTimeoutMs: command.timeoutMs, }); } const rawText = await readResponseTextFromSse(response, { + deadline, maxBytes: CODEX_SSE_RESPONSE_MAX_BYTES, maxEventChars: CODEX_SSE_EVENT_MAX_CHARS, - readTimeoutMs: command.timeoutMs, }); return { model: command.model, @@ -826,7 +873,7 @@ async function fetchChatGptTranscription( args: ChatGptTranscriptionFetchArgs, ): Promise { return runWithTimeout({ - timeoutMs: args.command.timeoutMs, + deadline: args.deadline, work: (signal) => fetchChatGpt({ url: CHATGPT_TRANSCRIBE_URL, @@ -844,7 +891,7 @@ async function fetchOpenAiTranscription( args: OpenAiTranscriptionFetchArgs, ): Promise { return runWithTimeout({ - timeoutMs: args.command.timeoutMs, + deadline: args.deadline, work: (signal) => fetch(OPENAI_TRANSCRIBE_URL, { method: "POST", @@ -862,31 +909,34 @@ async function fetchTranscription( ? fetchChatGptTranscription({ auth: args.auth, command: args.command, + deadline: args.deadline, }) : fetchOpenAiTranscription({ auth: args.auth, command: args.command, + deadline: args.deadline, }); } export async function transcribeCodexVoice( command: VoiceTranscribeCommand, ): Promise> { + const deadline = createCodexRequestDeadline(command.timeoutMs); const auth = await readCodexAuthCredentials(); - const response = await fetchTranscription({ auth, command }); + const response = await fetchTranscription({ auth, command, deadline }); if (!response.ok) { throw await createCodexHttpError({ + deadline, operation: "transcription", response, - readTimeoutMs: command.timeoutMs, }); } const responseText = await readLimitedResponseText(response, { + deadline, maxBytes: CODEX_TRANSCRIPTION_RESPONSE_MAX_BYTES, overflowBehavior: "throw", - readTimeoutMs: command.timeoutMs, }); return { diff --git a/apps/server/src/services/ai/inference.ts b/apps/server/src/services/ai/inference.ts index f4dbdafccb..e85a631ca3 100644 --- a/apps/server/src/services/ai/inference.ts +++ b/apps/server/src/services/ai/inference.ts @@ -50,6 +50,10 @@ function getInferenceModel( const RESULT_TOOL_NAME = "result"; const DEFAULT_INFERENCE_TIMEOUT_MS = 30_000; +// The command timeout is enforced by the daemon around the provider request. +// Leave enough time for its settled response to cross the host RPC boundary so +// the server does not discard a useful timeout or completion as stale. +const CODEX_INFERENCE_HOST_RPC_GRACE_MS = 1_000; interface InferenceCompleteArgs { prompt: string; @@ -126,7 +130,7 @@ async function completeWithCodexHostDaemon( try { const result = await runLiveCommandAndWait(deps, { hostId, - timeoutMs, + timeoutMs: timeoutMs + CODEX_INFERENCE_HOST_RPC_GRACE_MS, command: { type: "codex.inference.complete", model: modelInfo.modelId, diff --git a/apps/server/src/services/ai/voice-transcription.ts b/apps/server/src/services/ai/voice-transcription.ts index 7418212805..24b9136e04 100644 --- a/apps/server/src/services/ai/voice-transcription.ts +++ b/apps/server/src/services/ai/voice-transcription.ts @@ -22,6 +22,7 @@ type OptionalJsonValue = JsonValue | null | undefined; const OPENAI_TRANSCRIPTION_PROVIDER = "openai"; const VOICE_TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024; const CODEX_VOICE_TRANSCRIPTION_ATTEMPT_TIMEOUT_MS = 10_000; +const CODEX_VOICE_TRANSCRIPTION_HOST_RPC_GRACE_MS = 1_000; const CODEX_VOICE_TRANSCRIPTION_MAX_ATTEMPTS = 2; const CODEX_VOICE_TRANSCRIPTION_RETRY_DELAY_MS = 250; const OPENAI_VOICE_TRANSCRIPTION_TIMEOUT_MS = 10_000; @@ -149,7 +150,9 @@ async function transcribeWithCodexHostDaemon( try { const result = await runLiveCommandAndWait(deps, { hostId, - timeoutMs: CODEX_VOICE_TRANSCRIPTION_ATTEMPT_TIMEOUT_MS, + timeoutMs: + CODEX_VOICE_TRANSCRIPTION_ATTEMPT_TIMEOUT_MS + + CODEX_VOICE_TRANSCRIPTION_HOST_RPC_GRACE_MS, command: { type: "codex.voice.transcribe", model: modelInfo.modelId, diff --git a/apps/server/src/services/threads/thread-metadata-inference.ts b/apps/server/src/services/threads/thread-metadata-inference.ts index 9306a2bd38..0d02a0cc4d 100644 --- a/apps/server/src/services/threads/thread-metadata-inference.ts +++ b/apps/server/src/services/threads/thread-metadata-inference.ts @@ -10,9 +10,10 @@ import { runtimeErrorLogFields } from "../lib/error-log-fields.js"; type ThreadMetadataInferenceDeps = LoggedWorkSessionDeps; -// Two 2.5s attempts preserve roughly the old 5s managed provisioning -// blocking budget while recovering transient metadata inference timeouts. -export const MANAGED_THREAD_METADATA_TIMEOUT_MS = 2_500; +// Luna commonly needs more than 2.5s for structured output. Two 5s attempts +// let the first ordinary completion finish and still recover a transient +// timeout or service-unavailable response. +export const MANAGED_THREAD_METADATA_TIMEOUT_MS = 5_000; export const MANAGED_THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS = 2; export interface ThreadMetadataInferenceArgs { diff --git a/apps/server/src/services/threads/title-generation.ts b/apps/server/src/services/threads/title-generation.ts index 9917e4f0a0..4a3d406476 100644 --- a/apps/server/src/services/threads/title-generation.ts +++ b/apps/server/src/services/threads/title-generation.ts @@ -1,14 +1,17 @@ +import { setTimeout as delay } from "node:timers/promises"; import { renderTemplate } from "@bb/templates"; import { getThread, updateThread } from "@bb/db"; import type { PromptInput } from "@bb/domain"; import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js"; import { Type } from "@earendil-works/pi-ai"; +import { ApiError } from "../../errors.js"; import { InferenceTimeoutError, inferenceComplete } from "../ai/inference.js"; import { runtimeErrorLogFields } from "../lib/error-log-fields.js"; const MIN_TITLE_GENERATION_WORDS = 5; const MAX_GENERATED_TITLE_WORDS = 5; const MAX_BRANCH_SLUG_LENGTH = 48; +const THREAD_METADATA_RETRY_DELAY_MS = 250; type ThreadMetadataGenerationDeps = LoggedWorkSessionDeps; type ThreadTitleApplyDeps = Pick; @@ -124,6 +127,15 @@ function normalizeGeneratedThreadMetadata( }; } +function isRetryableThreadMetadataError(error: Error): boolean { + return ( + error instanceof InferenceTimeoutError || + (error instanceof ApiError && + (error.body.code === "codex_rate_limited" || + error.body.code === "codex_service_unavailable")) + ); +} + export async function generateThreadMetadataWithOutcome( deps: ThreadMetadataGenerationDeps, args: ThreadMetadataGenerationArgs, @@ -167,40 +179,50 @@ export async function generateThreadMetadataWithOutcome( durationMs: Date.now() - startedAt, threadId: args.threadId, }, - "Thread metadata inference completed after timeout retry", + "Thread metadata inference completed after transient retry", ); } return complete(metadata, metadata ? undefined : "inference-unavailable"); } catch (error) { - if (error instanceof InferenceTimeoutError) { + const err = + error instanceof Error + ? error + : new Error("Non-Error thrown during thread metadata generation"); + if (isRetryableThreadMetadataError(err)) { if (attempt < maxAttempts) { deps.logger.info( { attempt, + errorCode: err instanceof ApiError ? err.body.code : "timeout", maxAttempts, threadId: args.threadId, - timeoutMs: error.timeoutMs, + ...(err instanceof InferenceTimeoutError + ? { timeoutMs: err.timeoutMs } + : {}), }, - "Thread metadata inference timed out; retrying", + "Thread metadata inference failed transiently; retrying", ); + await delay(THREAD_METADATA_RETRY_DELAY_MS); continue; } - deps.logger.info( - { - attempts: maxAttempts, - threadId: args.threadId, - timeoutMs: error.timeoutMs, - }, - "Thread metadata inference timed out", - ); - return complete(null, "timeout"); + if (err instanceof InferenceTimeoutError) { + deps.logger.info( + { + attempts: maxAttempts, + threadId: args.threadId, + timeoutMs: err.timeoutMs, + }, + "Thread metadata inference timed out", + ); + return complete(null, "timeout"); + } } deps.logger.warn( { threadId: args.threadId, - ...runtimeErrorLogFields(deps.config, error), + ...runtimeErrorLogFields(deps.config, err), }, "Failed to generate thread metadata", ); diff --git a/apps/server/test/ai/inference.test.ts b/apps/server/test/ai/inference.test.ts index 6e8c89a4b3..6f5fb191b2 100644 --- a/apps/server/test/ai/inference.test.ts +++ b/apps/server/test/ai/inference.test.ts @@ -1,3 +1,4 @@ +import { setTimeout as delay } from "node:timers/promises"; import { Type } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; import { @@ -71,6 +72,33 @@ describe("inferenceComplete", () => { }); }); + it("leaves grace for a daemon result to cross the host RPC boundary", async () => { + await withTestHarness({ + inferenceModel: "codex/gpt-5.6-luna", + }, async (harness) => { + seedHostSession(harness.deps); + const completion = inferenceComplete(harness.deps, { + prompt: "Generate a title", + schema: titleSchema, + timeoutMs: 5, + }); + + const queued = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "codex.inference.complete", + ); + await delay(20); + await reportQueuedCommandSuccess(harness, queued, { + model: "gpt-5.6-luna", + value: { title: "Generated title" }, + }); + + await expect(completion).resolves.toEqual({ + title: "Generated title", + }); + }); + }); + it("converts codex daemon timeouts into inference timeouts", async () => { await withTestHarness({ inferenceModel: "codex/gpt-5.6-luna", diff --git a/apps/server/test/threads/generated-branch-names.test.ts b/apps/server/test/threads/generated-branch-names.test.ts index 025bd06cf0..860d1e27aa 100644 --- a/apps/server/test/threads/generated-branch-names.test.ts +++ b/apps/server/test/threads/generated-branch-names.test.ts @@ -27,6 +27,7 @@ import { seedTurnStarted, } from "../helpers/seed.js"; import { createTestAppHarness, withTestHarness } from "../helpers/test-app.js"; +import { ApiError } from "../../src/errors.js"; import { InferenceTimeoutError } from "../../src/services/ai/inference.js"; import { runEnvironmentProvisioningSweep } from "../../src/services/system/periodic-sweeps.js"; import { createThreadFromRequest } from "../../src/services/threads/thread-create.js"; @@ -1225,14 +1226,14 @@ describe("generated managed branch names", () => { threadId: "thr_retry_timeout", timeoutMs: 1, }), - "Thread metadata inference timed out; retrying", + "Thread metadata inference failed transiently; retrying", ); expect(infoSpy).toHaveBeenCalledWith( expect.objectContaining({ attempts: 2, threadId: "thr_retry_timeout", }), - "Thread metadata inference completed after timeout retry", + "Thread metadata inference completed after transient retry", ); } finally { infoSpy.mockRestore(); @@ -1240,7 +1241,42 @@ describe("generated managed branch names", () => { } }); - it("does not retry non-timeout metadata inference failures", async () => { + it("retries transient Codex service failures", async () => { + piAiMocks.getModel.mockReturnValue({ provider: "test" }); + piAiMocks.complete + .mockRejectedValueOnce( + new ApiError( + 502, + "codex_service_unavailable", + "Our servers are currently overloaded. Please try again later.", + false, + ), + ) + .mockResolvedValueOnce( + mockThreadMetadataCompletion({ + title: "Recovered Metadata", + }), + ); + + await withTestHarness(async (harness) => { + await expect( + generateThreadMetadataWithOutcome(harness.deps, { + input: textInput("Recover transient metadata provider failures"), + threadId: "thr_retry_service_unavailable", + timeoutMaxAttempts: 2, + timeoutMs: 1_000, + }), + ).resolves.toMatchObject({ + metadata: { + branchSlug: "recovered-metadata", + title: "Recovered Metadata", + }, + }); + expect(piAiMocks.complete).toHaveBeenCalledTimes(2); + }); + }); + + it("does not retry non-transient metadata inference failures", async () => { piAiMocks.getModel.mockReturnValue({ provider: "test" }); piAiMocks.complete.mockRejectedValue(new Error("metadata failed")); await withTestHarness(async (harness) => { diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index c0c10ae15e..755e532a9d 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -36,7 +36,7 @@ import { providerCliStatusResponseSchema, } from "./local.js"; -export const HOST_DAEMON_PROTOCOL_VERSION = 99 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 100 as const; export { BRANCH_LIST_LIMIT_MAX, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 92dfec488a..b58f2a9a17 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1051,10 +1051,12 @@ describe("host-daemon local schemas", () => { }); describe("host-daemon command schemas", () => { - // Version 99 moves Claude workflow/subagent enforcement from the session - // payload into live adapter controls, so an older daemon must update. - it("uses protocol version 99 for live Claude feature settings", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(99); + // Version 100 makes Codex inference timeoutMs an end-to-end daemon deadline + // and classifies streamed service-unavailable failures for bounded retries. + // An older daemon can outlive the server's wait or hide a retryable failure, + // so it must update before connecting. + it("uses protocol version 100 for Codex inference deadlines", () => { + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(100); }); it("binds Plan cancellation to a required turn id and typed result", () => { From 88a301606ac296a1c4f98daa007529b9ded688ed Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 11 Aug 2026 10:15:44 -0700 Subject: [PATCH 2/5] Add configurable inference fallback model --- apps/server/src/services/ai/commit-message.ts | 60 ++++++++++++------- apps/server/src/services/ai/inference.ts | 23 ++++--- .../skills/builtin-skills/bb-cli/SKILL.md | 12 ++-- .../services/system/bb-app-managed-config.ts | 5 ++ .../src/services/threads/title-generation.ts | 26 ++++---- apps/server/src/start-server.ts | 1 + apps/server/src/types.ts | 1 + apps/server/test/ai/commit-message.test.ts | 58 ++++++++++++++++-- apps/server/test/ai/inference.test.ts | 31 +++++++++- apps/server/test/helpers/test-app.ts | 1 + .../test/system/bb-app-managed-config.test.ts | 29 +++++++++ .../threads/generated-branch-names.test.ts | 21 +++++++ docs/configuration.md | 34 ++++++----- packages/bb-app/README.md | 1 + packages/bb-app/src/launcher.ts | 12 +++- packages/bb-app/test/index.test.ts | 18 ++++++ packages/config/src/bb-app-managed-config.ts | 3 + packages/config/src/defaults.ts | 1 + packages/config/src/env-vars.ts | 13 ++++ packages/config/src/inference-model.ts | 7 +++ packages/config/src/server.ts | 9 +++ packages/config/test/config.test.ts | 24 ++++++++ .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-customization.md | 17 ++++-- 24 files changed, 337 insertions(+), 72 deletions(-) diff --git a/apps/server/src/services/ai/commit-message.ts b/apps/server/src/services/ai/commit-message.ts index c03c50c473..89e98ce2ed 100644 --- a/apps/server/src/services/ai/commit-message.ts +++ b/apps/server/src/services/ai/commit-message.ts @@ -1,7 +1,12 @@ import { renderTemplate } from "@bb/templates"; import type { LoggedWorkSessionDeps } from "../../types.js"; import { Type } from "@earendil-works/pi-ai"; -import { InferenceTimeoutError, inferenceComplete } from "./inference.js"; +import { ApiError } from "../../errors.js"; +import { + InferenceTimeoutError, + inferenceComplete, + isTransientInferenceError, +} from "./inference.js"; import { runtimeErrorLogFields } from "../lib/error-log-fields.js"; const commitMessageSchema = Type.Object({ @@ -26,8 +31,7 @@ interface CommitMessageGenerationOutcome { } const COMMIT_MESSAGE_TIMEOUT_MS = 5_000; -// Two 5s attempts preserve the previous 10s worst-case fallback budget while -// recovering transient provider stalls. +// The primary and fallback models each receive one bounded attempt. const COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS = 2; async function generateCommitMessageWithOutcome( @@ -58,8 +62,13 @@ async function generateCommitMessageWithOutcome( attempt <= COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS; attempt += 1 ) { + const model = + attempt === 1 + ? deps.config.inferenceModel + : deps.config.inferenceFallbackModel; try { const result = await inferenceComplete(deps, { + model, prompt, schema: commitMessageSchema, timeoutMs: COMMIT_MESSAGE_TIMEOUT_MS, @@ -85,10 +94,11 @@ async function generateCommitMessageWithOutcome( attempts: outcome.attempts, durationMs: outcome.durationMs, maxAttempts: COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS, - reason: "timeout", + model, + reason: "transient-failure", timeoutMs: COMMIT_MESSAGE_TIMEOUT_MS, }, - "Commit message inference completed after timeout retry", + "Commit message inference completed with fallback model", ); } return outcome; @@ -97,32 +107,40 @@ async function generateCommitMessageWithOutcome( error instanceof Error ? error : new Error("Non-Error thrown during commit message generation"); - if (err instanceof InferenceTimeoutError) { + if (isTransientInferenceError(err)) { if (attempt < COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS) { deps.logger.info( { attempt, + errorCode: err instanceof ApiError ? err.body.code : "timeout", + fallbackModel: deps.config.inferenceFallbackModel, maxAttempts: COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS, - reason: "timeout", - timeoutMs: err.timeoutMs, + model, + reason: "transient-failure", + ...(err instanceof InferenceTimeoutError + ? { timeoutMs: err.timeoutMs } + : {}), }, - "Commit message inference timed out; retrying", + "Commit message inference failed transiently; using fallback model", ); continue; } - const outcome = complete(null, attempt, "timeout"); - deps.logger.info( - { - attempts: outcome.attempts, - durationMs: outcome.durationMs, - maxAttempts: COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS, - reason: outcome.reason, - timeoutMs: err.timeoutMs, - }, - "Commit message inference timed out", - ); - return outcome; + if (err instanceof InferenceTimeoutError) { + const outcome = complete(null, attempt, "timeout"); + deps.logger.info( + { + attempts: outcome.attempts, + durationMs: outcome.durationMs, + maxAttempts: COMMIT_MESSAGE_TIMEOUT_MAX_ATTEMPTS, + model, + reason: outcome.reason, + timeoutMs: err.timeoutMs, + }, + "Commit message inference timed out", + ); + return outcome; + } } const reason: CommitMessageGenerationReason = "failed"; diff --git a/apps/server/src/services/ai/inference.ts b/apps/server/src/services/ai/inference.ts index e85a631ca3..f4c94c706e 100644 --- a/apps/server/src/services/ai/inference.ts +++ b/apps/server/src/services/ai/inference.ts @@ -29,11 +29,8 @@ function getInferenceModels(): InferenceModels { function getInferenceModel( deps: BaseInferenceDeps, + modelInfo: ProviderModelInfo, ): ReturnType | null { - const modelInfo = parseProviderModelConfig({ - name: "BB_INFERENCE", - value: deps.config.inferenceModel, - }); const model = getInferenceModels().getModel( modelInfo.provider, modelInfo.modelId, @@ -56,6 +53,7 @@ const DEFAULT_INFERENCE_TIMEOUT_MS = 30_000; const CODEX_INFERENCE_HOST_RPC_GRACE_MS = 1_000; interface InferenceCompleteArgs { + model?: string; prompt: string; schema: T; timeoutMs?: number; @@ -120,6 +118,15 @@ function shouldTreatAsInferenceTimeout(error: Error): boolean { ); } +export function isTransientInferenceError(error: Error): boolean { + return ( + error instanceof InferenceTimeoutError || + (error instanceof ApiError && + (error.body.code === "codex_rate_limited" || + error.body.code === "codex_service_unavailable")) + ); +} + async function completeWithCodexHostDaemon( deps: InferenceCompleteDeps, modelInfo: ProviderModelInfo, @@ -167,15 +174,17 @@ export async function inferenceComplete( deps: InferenceCompleteDeps, args: InferenceCompleteArgs, ): Promise | null> { + const configuredModel = args.model ?? deps.config.inferenceModel; const modelInfo = parseProviderModelConfig({ - name: "BB_INFERENCE", - value: deps.config.inferenceModel, + name: + args.model === undefined ? "BB_INFERENCE" : "inference model override", + value: configuredModel, }); if (backsHostDaemonAiServices(modelInfo.provider)) { return completeWithCodexHostDaemon(deps, modelInfo, args); } - const model = getInferenceModel(deps); + const model = getInferenceModel(deps, modelInfo); if (!model) { return null; } diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 98d4561aff..6de97af7cc 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -53,6 +53,10 @@ message agents, or inspect projects, providers, and environments. including thread titles and commit subjects. It defaults to `codex/gpt-5.6-luna`; set an override with `bb-app config set BB_INFERENCE `. +- `BB_INFERENCE_FALLBACK` selects the helper model used after a transient + primary timeout, rate limit, or service-unavailable failure. It defaults to + `codex/gpt-5.4-mini`; set it with + `bb-app config set BB_INFERENCE_FALLBACK `. - `BB_TRANSCRIPTION` selects the voice transcription model. It defaults to `codex/gpt-transcribe`; set an override with `bb-app config set BB_TRANSCRIPTION `. @@ -60,10 +64,10 @@ message agents, or inspect projects, providers, and environments. but the CLI identifies server and launcher settings that are startup-only, including binding/ports, data and the dev-app port, telemetry, inherited skill roots, and `BB_FF_*` flags. `BB_LOG_LEVEL` is also startup-only. Use - `bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, or - `BB_TRANSCRIPTION` live. After a startup-only change, run - `bb-app stop && bb-app start` or restart the desktop app. Until then, a server - previously bound to `0.0.0.0` remains exposed even if + `bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, + `BB_INFERENCE_FALLBACK`, or `BB_TRANSCRIPTION` live. After a startup-only + change, run `bb-app stop && bb-app start` or restart the desktop app. Until + then, a server previously bound to `0.0.0.0` remains exposed even if `BB_SERVER_BIND_HOST` was changed or unset. - Settings → General holds server-backed app-wide preferences, such as the macOS-only "Caffeinate" toggle. For details, read diff --git a/apps/server/src/services/system/bb-app-managed-config.ts b/apps/server/src/services/system/bb-app-managed-config.ts index 8af2ad4a96..c6af833d5e 100644 --- a/apps/server/src/services/system/bb-app-managed-config.ts +++ b/apps/server/src/services/system/bb-app-managed-config.ts @@ -9,6 +9,7 @@ import { type BbAppManagedEnvFile, } from "@bb/config/bb-app-managed-config"; import { + validateInferenceFallbackModel, validateInferenceModel, validateTranscriptionModel, } from "@bb/config/inference-model"; @@ -111,6 +112,10 @@ export function applyBbAppManagedConfig( managedConfig.BB_INFERENCE !== undefined ? validateInferenceModel(managedConfig.BB_INFERENCE) : args.baseConfig.inferenceModel; + args.targetConfig.inferenceFallbackModel = + managedConfig.BB_INFERENCE_FALLBACK !== undefined + ? validateInferenceFallbackModel(managedConfig.BB_INFERENCE_FALLBACK) + : args.baseConfig.inferenceFallbackModel; args.targetConfig.transcriptionModel = managedConfig.BB_TRANSCRIPTION !== undefined ? validateTranscriptionModel(managedConfig.BB_TRANSCRIPTION) diff --git a/apps/server/src/services/threads/title-generation.ts b/apps/server/src/services/threads/title-generation.ts index 4a3d406476..16918eca0b 100644 --- a/apps/server/src/services/threads/title-generation.ts +++ b/apps/server/src/services/threads/title-generation.ts @@ -5,7 +5,11 @@ import type { PromptInput } from "@bb/domain"; import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js"; import { Type } from "@earendil-works/pi-ai"; import { ApiError } from "../../errors.js"; -import { InferenceTimeoutError, inferenceComplete } from "../ai/inference.js"; +import { + InferenceTimeoutError, + inferenceComplete, + isTransientInferenceError, +} from "../ai/inference.js"; import { runtimeErrorLogFields } from "../lib/error-log-fields.js"; const MIN_TITLE_GENERATION_WORDS = 5; @@ -127,15 +131,6 @@ function normalizeGeneratedThreadMetadata( }; } -function isRetryableThreadMetadataError(error: Error): boolean { - return ( - error instanceof InferenceTimeoutError || - (error instanceof ApiError && - (error.body.code === "codex_rate_limited" || - error.body.code === "codex_service_unavailable")) - ); -} - export async function generateThreadMetadataWithOutcome( deps: ThreadMetadataGenerationDeps, args: ThreadMetadataGenerationArgs, @@ -164,8 +159,13 @@ export async function generateThreadMetadataWithOutcome( const maxAttempts = Math.max(1, args.timeoutMaxAttempts ?? 1); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const model = + attempt === 1 + ? deps.config.inferenceModel + : deps.config.inferenceFallbackModel; try { const parsed = await inferenceComplete(deps, { + model, prompt, schema: threadMetadataSchema, ...(args.timeoutMs ? { timeoutMs: args.timeoutMs } : {}), @@ -177,6 +177,7 @@ export async function generateThreadMetadataWithOutcome( { attempts: attempt, durationMs: Date.now() - startedAt, + model, threadId: args.threadId, }, "Thread metadata inference completed after transient retry", @@ -188,13 +189,15 @@ export async function generateThreadMetadataWithOutcome( error instanceof Error ? error : new Error("Non-Error thrown during thread metadata generation"); - if (isRetryableThreadMetadataError(err)) { + if (isTransientInferenceError(err)) { if (attempt < maxAttempts) { deps.logger.info( { attempt, errorCode: err instanceof ApiError ? err.body.code : "timeout", + fallbackModel: deps.config.inferenceFallbackModel, maxAttempts, + model, threadId: args.threadId, ...(err instanceof InferenceTimeoutError ? { timeoutMs: err.timeoutMs } @@ -210,6 +213,7 @@ export async function generateThreadMetadataWithOutcome( deps.logger.info( { attempts: maxAttempts, + model, threadId: args.threadId, timeoutMs: err.timeoutMs, }, diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index 38b5cb39f5..33a73b837f 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -76,6 +76,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { featureFlags: serverConfig.featureFlags, hostDaemonPort: serverConfig.BB_HOST_DAEMON_PORT, inheritedSkillsRootPaths: serverConfig.BB_INHERITED_SKILLS_ROOTS, + inferenceFallbackModel: serverConfig.BB_INFERENCE_FALLBACK, inferenceModel: serverConfig.BB_INFERENCE, isDevelopment: !isProduction, managedEnvironmentRetireGraceMs: MANAGED_ENVIRONMENT_RETIRE_GRACE_MS, diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 118eb0f1c9..d19188a82f 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -30,6 +30,7 @@ export interface ServerRuntimeConfig { featureFlags: FeatureFlags; hostDaemonPort: number; inheritedSkillsRootPaths: string[]; + inferenceFallbackModel: string; inferenceModel: string; isDevelopment: boolean; /** diff --git a/apps/server/test/ai/commit-message.test.ts b/apps/server/test/ai/commit-message.test.ts index a09173bb5e..e9b7071cf9 100644 --- a/apps/server/test/ai/commit-message.test.ts +++ b/apps/server/test/ai/commit-message.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../src/errors.js"; import { generateCommitMessage } from "../../src/services/ai/commit-message.js"; import { InferenceTimeoutError } from "../../src/services/ai/inference.js"; import type { AppDeps, LoggedWorkSessionDeps } from "../../src/types.js"; @@ -120,21 +121,70 @@ describe("commit message generation", () => { expect(message).toBe("fix: recover commit message"); expect(piAiMocks.complete).toHaveBeenCalledTimes(2); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 1, + "test", + "mock-model", + ); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 2, + "test", + "mock-fallback-model", + ); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ attempt: 1, + fallbackModel: "test/mock-fallback-model", maxAttempts: 2, - reason: "timeout", + reason: "transient-failure", timeoutMs: 5_000, }), - "Commit message inference timed out; retrying", + "Commit message inference failed transiently; using fallback model", ); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ attempts: 2, - reason: "timeout", + model: "test/mock-fallback-model", + reason: "transient-failure", + }), + "Commit message inference completed with fallback model", + ); + } finally { + await cleanup(); + } + }); + + it("uses the fallback model after transient service unavailability", async () => { + piAiMocks.complete + .mockRejectedValueOnce( + new ApiError( + 502, + "codex_service_unavailable", + "Our servers are currently overloaded. Please try again later.", + false, + ), + ) + .mockResolvedValueOnce( + mockCommitMessageCompletion({ + message: "fix: recover with fallback model", + }), + ); + const { cleanup, deps, logger } = await createCommitMessageDeps(); + try { + await expect( + generateCommitMessage(deps, commitMessageArgs), + ).resolves.toBe("fix: recover with fallback model"); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 2, + "test", + "mock-fallback-model", + ); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + errorCode: "codex_service_unavailable", + fallbackModel: "test/mock-fallback-model", }), - "Commit message inference completed after timeout retry", + "Commit message inference failed transiently; using fallback model", ); } finally { await cleanup(); diff --git a/apps/server/test/ai/inference.test.ts b/apps/server/test/ai/inference.test.ts index 6f5fb191b2..9f296a545f 100644 --- a/apps/server/test/ai/inference.test.ts +++ b/apps/server/test/ai/inference.test.ts @@ -16,7 +16,6 @@ import { withTestHarness } from "../helpers/test-app.js"; const titleSchema = Type.Object({ title: Type.String(), }); - describe("inferenceComplete", () => { it("surfaces missing host for codex inference", async () => { await withTestHarness({ @@ -72,6 +71,36 @@ describe("inferenceComplete", () => { }); }); + it("routes an explicit fallback model instead of the configured primary", async () => { + await withTestHarness({ + inferenceModel: "codex/gpt-5.6-luna", + }, async (harness) => { + seedHostSession(harness.deps); + const completion = inferenceComplete(harness.deps, { + model: "codex/gpt-5.4-mini", + prompt: "Generate a title", + schema: titleSchema, + timeoutMs: 5000, + }); + + const queued = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "codex.inference.complete", + ); + expect(queued.command).toMatchObject({ + model: "gpt-5.4-mini", + type: "codex.inference.complete", + }); + + await reportQueuedCommandSuccess(harness, queued, { + model: "gpt-5.4-mini", + value: { title: "Fallback title" }, + }); + + await expect(completion).resolves.toEqual({ title: "Fallback title" }); + }); + }); + it("leaves grace for a daemon result to cross the host RPC boundary", async () => { await withTestHarness({ inferenceModel: "codex/gpt-5.6-luna", diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index fc1c0e7f80..9e28830939 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -131,6 +131,7 @@ export async function createTestAppHarness( featureFlags: defaultFeatureFlags, hostDaemonPort: 3001, inheritedSkillsRootPaths: [], + inferenceFallbackModel: "test/mock-fallback-model", inferenceModel: "test/mock-model", isDevelopment: true, managedEnvironmentRetireGraceMs: MANAGED_ENVIRONMENT_RETIRE_GRACE_MS, diff --git a/apps/server/test/system/bb-app-managed-config.test.ts b/apps/server/test/system/bb-app-managed-config.test.ts index e2399d4871..d6f06b8d3c 100644 --- a/apps/server/test/system/bb-app-managed-config.test.ts +++ b/apps/server/test/system/bb-app-managed-config.test.ts @@ -70,6 +70,7 @@ function createRuntimeConfig(): ServerRuntimeConfig { featureFlags: defaultFeatureFlags, hostDaemonPort: 38887, inheritedSkillsRootPaths: [], + inferenceFallbackModel: "openai/gpt-4o-mini-fallback", inferenceModel: "openai/gpt-4o-mini", isDevelopment: false, managedEnvironmentRetireGraceMs: 5 * 60_000, @@ -92,6 +93,7 @@ describe("bb-app managed config", () => { config: { BB_APP_URL: "https://stored-app.example.test", BB_INFERENCE: "anthropic/claude-sonnet-4-5", + BB_INFERENCE_FALLBACK: "openai/gpt-5.4-mini", BB_TRANSCRIPTION: "openai/gpt-4o-transcribe", }, }, @@ -105,6 +107,7 @@ describe("bb-app managed config", () => { expect(targetConfig).toMatchObject({ appUrl: "https://stored-app.example.test", + inferenceFallbackModel: "openai/gpt-5.4-mini", inferenceModel: "anthropic/claude-sonnet-4-5", openAiApiKey: "stored-openai-key", transcriptionModel: "openai/gpt-4o-transcribe", @@ -294,6 +297,24 @@ describe("bb-app managed config", () => { ).toThrow(/BB_INFERENCE/u); }); + it("rejects invalid inference fallback model config", () => { + const baseConfig = createRuntimeConfig(); + const targetConfig = createRuntimeConfig(); + + expect(() => + applyBbAppManagedConfig({ + baseConfig, + managedConfig: { + config: { + BB_INFERENCE_FALLBACK: "gpt-5.4-mini", + }, + }, + managedEnvFile: {}, + targetConfig, + }), + ).toThrow(/BB_INFERENCE_FALLBACK/u); + }); + it("reloads config file changes and notifies clients", async () => { const dataDir = mkdtempSync(join(tmpdir(), "bb-managed-config-")); const socket = createMockHubSocket(); @@ -311,6 +332,13 @@ describe("bb-app managed config", () => { }); try { + writeFileSync( + formatBbAppConfigPath(dataDir), + `${JSON.stringify({ + config: { BB_INFERENCE_FALLBACK: "codex/gpt-5.4-mini" }, + })}\n`, + "utf8", + ); writeFileSync( formatBbAppEnvPath(dataDir), `${JSON.stringify({ env: { OPENAI_API_KEY: "live-openai-key" } })}\n`, @@ -318,6 +346,7 @@ describe("bb-app managed config", () => { ); await reloader.reload({ notify: true }); + expect(config.inferenceFallbackModel).toBe("codex/gpt-5.4-mini"); expect(config.openAiApiKey).toBe("live-openai-key"); expect( socket.messages.some((message) => message.includes("config-changed")), diff --git a/apps/server/test/threads/generated-branch-names.test.ts b/apps/server/test/threads/generated-branch-names.test.ts index 860d1e27aa..1412cfff3c 100644 --- a/apps/server/test/threads/generated-branch-names.test.ts +++ b/apps/server/test/threads/generated-branch-names.test.ts @@ -1219,9 +1219,20 @@ describe("generated managed branch names", () => { }, }); expect(piAiMocks.complete).toHaveBeenCalledTimes(2); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 1, + "test", + "mock-model", + ); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 2, + "test", + "mock-fallback-model", + ); expect(infoSpy).toHaveBeenCalledWith( expect.objectContaining({ attempt: 1, + fallbackModel: "test/mock-fallback-model", maxAttempts: 2, threadId: "thr_retry_timeout", timeoutMs: 1, @@ -1273,6 +1284,16 @@ describe("generated managed branch names", () => { }, }); expect(piAiMocks.complete).toHaveBeenCalledTimes(2); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 1, + "test", + "mock-model", + ); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 2, + "test", + "mock-fallback-model", + ); }); }); diff --git a/docs/configuration.md b/docs/configuration.md index c495835965..9cabbd8245 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,7 @@ Use `bb-app config` for non-secret bb settings: ```bash npx bb-app config set BB_APP_URL https://..ts.net npx bb-app config set BB_INFERENCE codex/gpt-5.6-luna +npx bb-app config set BB_INFERENCE_FALLBACK codex/gpt-5.4-mini npx bb-app config set BB_TRANSCRIPTION codex/gpt-transcribe npx bb-app config list npx bb-app config unset BB_APP_URL @@ -72,9 +73,9 @@ After `bb-app config` writes `~/.bb/config.json` or `bb-app env` writes running, the new values apply on the next start. If you edit either file by hand, run `npx bb-app config refresh` to apply the files to a running server. -The live reload applies config keys such as `BB_APP_URL`, `BB_INFERENCE`, and -`BB_TRANSCRIPTION`, plus env values explicitly consumed at runtime such as -`OPENAI_API_KEY`. If `BB_APP_URL`, `BB_INFERENCE`, or `BB_TRANSCRIPTION` is +The live reload applies config keys such as `BB_APP_URL`, `BB_INFERENCE`, +`BB_INFERENCE_FALLBACK`, and `BB_TRANSCRIPTION`, plus env values explicitly +consumed at runtime such as `OPENAI_API_KEY`. If one of those config keys is stored with `bb-app env` instead, it is startup-only; use `bb-app config` when you need a live change. @@ -82,8 +83,8 @@ you need a live change. set of startup-only server or launcher env entries is: - `BB_APP_SURFACE`, `BB_APP_URL`, `BB_DATA_DIR`, and `BB_DEV_APP_PORT` -- `BB_EXTERNAL_URL`, `BB_HOST_DAEMON_PORT`, `BB_INFERENCE`, and - `BB_INHERITED_SKILLS_ROOTS` +- `BB_EXTERNAL_URL`, `BB_HOST_DAEMON_PORT`, `BB_INFERENCE`, + `BB_INFERENCE_FALLBACK`, and `BB_INHERITED_SKILLS_ROOTS` - `BB_LOG_LEVEL`, `BB_MANAGED_DEV_BUILTIN_PLUGIN_HOT_RELOAD`, `BB_POSTHOG_API_KEY`, and `BB_TELEMETRY` - `BB_SERVER_BIND_HOST`, `BB_SERVER_PORT`, `BB_TRANSCRIPTION`, and all @@ -120,17 +121,18 @@ signal it, so a stale file left by a crash cannot stop an unrelated process. ## Common Keys -| Key | Command | When to set | Used for | -| --------------------- | -------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BB_APP_URL` | `bb-app config` | Optional for remote use | Human-facing app URL used for generated links and allowed browser origins. Leave empty for local-only use. | -| `BB_INFERENCE` | `bb-app config` | Optional | Server-side helper model in `provider/model` format. Defaults to `codex/gpt-5.6-luna`; the Codex helper route uses no reasoning. | -| `BB_TRANSCRIPTION` | `bb-app config` | Optional | Voice transcription model in `provider/model` format. Defaults to `codex/gpt-transcribe`. | -| `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset. | -| `BB_SERVER_BIND_HOST` | `bb-app env`, environment, or `--server-bind-host` | Startup-only | Server listener host. Defaults to `127.0.0.1`; accepts only `127.0.0.1` or `0.0.0.0`. A full launcher or desktop app restart is required; until then, a previous `0.0.0.0` listener remains exposed. This is not a `bb-app config` key. | -| `BB_SERVER_PORT` | `bb-app env`, environment, or `--server-port` | Startup-only | HTTP listener port. Defaults to `38886`. A full launcher or desktop app restart is required after a persistent set or unset. | -| `BB_HOST_DAEMON_PORT` | `bb-app env`, environment, or `--host-daemon-port` | Startup-only | Local host-daemon API port. Defaults to `38887`. A full launcher or desktop app restart is required after a persistent set or unset. | -| `BB_LOG_LEVEL` | `bb-app config` | Startup-only debugging | Log level: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. A full launcher or desktop app restart is required. | -| `OPENAI_API_KEY` | `bb-app env` | OpenAI opt-in routes | Required only when selecting explicit OpenAI provider routes such as `openai/gpt-4o-mini` or `openai/gpt-transcribe`. | +| Key | Command | When to set | Used for | +| ----------------------- | -------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BB_APP_URL` | `bb-app config` | Optional for remote use | Human-facing app URL used for generated links and allowed browser origins. Leave empty for local-only use. | +| `BB_INFERENCE` | `bb-app config` | Optional | Primary server-side helper model in `provider/model` format. Defaults to `codex/gpt-5.6-luna`; the Codex helper route uses no reasoning. | +| `BB_INFERENCE_FALLBACK` | `bb-app config` | Optional | Helper model used after a transient primary timeout, rate limit, or service-unavailable failure. Defaults to `codex/gpt-5.4-mini`. | +| `BB_TRANSCRIPTION` | `bb-app config` | Optional | Voice transcription model in `provider/model` format. Defaults to `codex/gpt-transcribe`. | +| `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset. | +| `BB_SERVER_BIND_HOST` | `bb-app env`, environment, or `--server-bind-host` | Startup-only | Server listener host. Defaults to `127.0.0.1`; accepts only `127.0.0.1` or `0.0.0.0`. A full launcher or desktop app restart is required; until then, a previous `0.0.0.0` listener remains exposed. This is not a `bb-app config` key. | +| `BB_SERVER_PORT` | `bb-app env`, environment, or `--server-port` | Startup-only | HTTP listener port. Defaults to `38886`. A full launcher or desktop app restart is required after a persistent set or unset. | +| `BB_HOST_DAEMON_PORT` | `bb-app env`, environment, or `--host-daemon-port` | Startup-only | Local host-daemon API port. Defaults to `38887`. A full launcher or desktop app restart is required after a persistent set or unset. | +| `BB_LOG_LEVEL` | `bb-app config` | Startup-only debugging | Log level: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. A full launcher or desktop app restart is required. | +| `OPENAI_API_KEY` | `bb-app env` | OpenAI opt-in routes | Required only when selecting explicit OpenAI provider routes such as `openai/gpt-4o-mini` or `openai/gpt-transcribe`. | By default, helper inference and voice transcription use Codex credentials from the host daemon. Run `codex login` on the host for the default path. Set diff --git a/packages/bb-app/README.md b/packages/bb-app/README.md index 5484aa29d8..3c1c4abc00 100644 --- a/packages/bb-app/README.md +++ b/packages/bb-app/README.md @@ -173,6 +173,7 @@ Use `bb-app config` for persistent non-secret package settings under ```bash npx bb-app config set BB_APP_URL https://..ts.net npx bb-app config set BB_INFERENCE codex/gpt-5.6-luna +npx bb-app config set BB_INFERENCE_FALLBACK codex/gpt-5.4-mini npx bb-app config set BB_TRANSCRIPTION codex/gpt-transcribe npx bb-app config list npx bb-app config refresh diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index d23e90e19c..6be24fe8f9 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -49,6 +49,7 @@ import { type ClientConfig, } from "@bb/config/client-config"; import { + validateInferenceFallbackModel, validateInferenceModel, validateTranscriptionModel, } from "@bb/config/inference-model"; @@ -107,6 +108,7 @@ const STARTUP_ONLY_MANAGED_ENV_KEYS = new Set([ "BB_EXTERNAL_URL", "BB_HOST_DAEMON_PORT", "BB_INFERENCE", + "BB_INFERENCE_FALLBACK", "BB_INHERITED_SKILLS_ROOTS", "BB_LOG_LEVEL", "BB_MANAGED_DEV_BUILTIN_PLUGIN_HOT_RELOAD", @@ -1144,6 +1146,9 @@ function validateManagedConfigForWrite(config: ManagedConfigForWrite): void { if (configValues.BB_INFERENCE !== undefined) { validateInferenceModel(configValues.BB_INFERENCE); } + if (configValues.BB_INFERENCE_FALLBACK !== undefined) { + validateInferenceFallbackModel(configValues.BB_INFERENCE_FALLBACK); + } if (configValues.BB_TRANSCRIPTION !== undefined) { validateTranscriptionModel(configValues.BB_TRANSCRIPTION); } @@ -1457,13 +1462,14 @@ Usage: Startup-only server and launcher keys: BB_APP_SURFACE, BB_APP_URL, BB_DATA_DIR, BB_DEV_APP_PORT, BB_EXTERNAL_URL, BB_HOST_DAEMON_PORT, BB_INFERENCE, - BB_INHERITED_SKILLS_ROOTS, BB_LOG_LEVEL, + BB_INFERENCE_FALLBACK, BB_INHERITED_SKILLS_ROOTS, BB_LOG_LEVEL, BB_MANAGED_DEV_BUILTIN_PLUGIN_HOT_RELOAD, BB_POSTHOG_API_KEY, BB_SERVER_BIND_HOST, BB_SERVER_PORT, BB_TELEMETRY, BB_TRANSCRIPTION, and BB_FF_* feature flags. Changes require a full bb-app restart with bb-app stop && bb-app start, - or a desktop app restart. BB_APP_URL, BB_INFERENCE, and BB_TRANSCRIPTION - can instead be changed live with bb-app config. + or a desktop app restart. BB_APP_URL, BB_INFERENCE, + BB_INFERENCE_FALLBACK, and BB_TRANSCRIPTION can instead be changed live + with bb-app config. Env file: ${formatBbAppEnvPath(dataDir)} diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index 38ab8c1286..0357392453 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -125,6 +125,11 @@ const invalidConfigCommandCases: InvalidConfigCommandCase[] = [ key: "BB_INFERENCE", value: "gpt-4o-mini", }, + { + expectedError: /BB_INFERENCE_FALLBACK must use provider\/model format/u, + key: "BB_INFERENCE_FALLBACK", + value: "gpt-5.4-mini", + }, { expectedError: /BB_TRANSCRIPTION must use provider\/model format/u, key: "BB_TRANSCRIPTION", @@ -157,6 +162,10 @@ const startupOnlyManagedEnvCases: StartupOnlyManagedEnvCase[] = [ { key: "BB_FF_TIMELINE_WINDOW_EVENT_BUDGET", value: "2000" }, { key: "BB_HOST_DAEMON_PORT", value: "48887" }, { key: "BB_INFERENCE", value: "codex/test-inference" }, + { + key: "BB_INFERENCE_FALLBACK", + value: "codex/test-inference-fallback", + }, { key: "BB_INHERITED_SKILLS_ROOTS", value: "/tmp/bb-skills" }, { key: "BB_LOG_LEVEL", value: "debug" }, { key: "BB_MANAGED_DEV_BUILTIN_PLUGIN_HOT_RELOAD", value: "1" }, @@ -969,6 +978,14 @@ describe("bb-app launcher", () => { "BB_INFERENCE", "anthropic/claude-sonnet-4-5", ]); + await runBbApp([ + "--data-dir", + dataDir, + "config", + "set", + "BB_INFERENCE_FALLBACK", + "codex/gpt-5.4-mini", + ]); await runBbApp([ "--data-dir", dataDir, @@ -984,6 +1001,7 @@ describe("bb-app launcher", () => { config: { BB_APP_URL: "https://bb.example.test", BB_INFERENCE: "anthropic/claude-sonnet-4-5", + BB_INFERENCE_FALLBACK: "codex/gpt-5.4-mini", }, }); expect(JSON.parse(readFileSync(join(dataDir, "env.json"), "utf8"))).toEqual( diff --git a/packages/config/src/bb-app-managed-config.ts b/packages/config/src/bb-app-managed-config.ts index e608eadd1a..7c428304b2 100644 --- a/packages/config/src/bb-app-managed-config.ts +++ b/packages/config/src/bb-app-managed-config.ts @@ -13,12 +13,14 @@ export const BB_APP_ENV_FILE_NAME = "env.json"; export type BbAppManagedConfigKey = | "BB_APP_URL" | "BB_INFERENCE" + | "BB_INFERENCE_FALLBACK" | "BB_LOG_LEVEL" | "BB_TRANSCRIPTION"; export const BB_APP_MANAGED_CONFIG_KEYS: BbAppManagedConfigKey[] = [ "BB_APP_URL", "BB_INFERENCE", + "BB_INFERENCE_FALLBACK", "BB_LOG_LEVEL", "BB_TRANSCRIPTION", ]; @@ -39,6 +41,7 @@ export const bbAppManagedConfigValuesSchema = z .object({ BB_APP_URL: z.string().optional(), BB_INFERENCE: z.string().optional(), + BB_INFERENCE_FALLBACK: z.string().optional(), BB_LOG_LEVEL: z.string().optional(), BB_TRANSCRIPTION: z.string().optional(), }) diff --git a/packages/config/src/defaults.ts b/packages/config/src/defaults.ts index 40fd2b4ade..e0c8a1b865 100644 --- a/packages/config/src/defaults.ts +++ b/packages/config/src/defaults.ts @@ -10,5 +10,6 @@ export const DEFAULTS = { logLevel: { prod: "info", dev: "debug" }, secretToken: { dev: "dev-secret" }, inferenceModel: "codex/gpt-5.6-luna", + inferenceFallbackModel: "codex/gpt-5.4-mini", transcriptionModel: "codex/gpt-transcribe", } as const; diff --git a/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index b259010c94..e21d8b21da 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -10,6 +10,7 @@ import { type AppSurface, } from "./app-surface.js"; import { + validateInferenceFallbackModel, validateInferenceModel, validateTranscriptionModel, } from "./inference-model.js"; @@ -132,6 +133,10 @@ function parseInferenceModelValue(args: EnvVarParseArgs): string { return validateInferenceModel(args.value); } +function parseInferenceFallbackModelValue(args: EnvVarParseArgs): string { + return validateInferenceFallbackModel(args.value); +} + function parseTranscriptionModelValue(args: EnvVarParseArgs): string { return validateTranscriptionModel(args.value); } @@ -214,6 +219,13 @@ export const BB_INFERENCE_ENV = defineEnvVar({ parse: parseInferenceModelValue, }); +export const BB_INFERENCE_FALLBACK_ENV = defineEnvVar({ + description: + "Fallback inference model used after a transient server-side completion failure", + name: "BB_INFERENCE_FALLBACK", + parse: parseInferenceFallbackModelValue, +}); + export const BB_TRANSCRIPTION_ENV = defineEnvVar({ description: "Speech-to-text model used for voice transcription", name: "BB_TRANSCRIPTION", @@ -359,6 +371,7 @@ export const DEFAULT_BB_POSTHOG_API_KEY = export const DEFAULT_BB_TELEMETRY = true; export const DEFAULT_BB_DEV_APP_HOST = ""; export const DEFAULT_BB_INFERENCE = DEFAULTS.inferenceModel; +export const DEFAULT_BB_INFERENCE_FALLBACK = DEFAULTS.inferenceFallbackModel; export const DEFAULT_BB_TRANSCRIPTION = DEFAULTS.transcriptionModel; export const DEFAULT_BB_FF_PLACEHOLDER = defaultFeatureFlags.placeholder; export const DEFAULT_BB_FF_TIMELINE_WINDOW_EVENT_BUDGET = diff --git a/packages/config/src/inference-model.ts b/packages/config/src/inference-model.ts index 4acf6ebf91..b57d09c8b6 100644 --- a/packages/config/src/inference-model.ts +++ b/packages/config/src/inference-model.ts @@ -40,6 +40,13 @@ export function validateInferenceModel(value: string): string { return validateProviderModelConfig({ name: "BB_INFERENCE", value }); } +export function validateInferenceFallbackModel(value: string): string { + return validateProviderModelConfig({ + name: "BB_INFERENCE_FALLBACK", + value, + }); +} + export function validateTranscriptionModel(value: string): string { return validateProviderModelConfig({ name: "BB_TRANSCRIPTION", value }); } diff --git a/packages/config/src/server.ts b/packages/config/src/server.ts index 0ea2756398..dc50fbb7d9 100644 --- a/packages/config/src/server.ts +++ b/packages/config/src/server.ts @@ -14,6 +14,7 @@ import { BB_APP_VERSION_ENV, BB_EXTERNAL_URL_ENV, BB_INHERITED_SKILLS_ROOTS_ENV, + BB_INFERENCE_FALLBACK_ENV, BB_INFERENCE_ENV, BB_POSTHOG_API_KEY_ENV, BB_SERVER_BIND_HOST_ENV, @@ -23,6 +24,7 @@ import { DEFAULT_BB_APP_SURFACE, DEFAULT_BB_APP_VERSION, DEFAULT_BB_EXTERNAL_URL, + DEFAULT_BB_INFERENCE_FALLBACK, DEFAULT_BB_INFERENCE, DEFAULT_BB_POSTHOG_API_KEY, DEFAULT_BB_SERVER_BIND_HOST, @@ -48,6 +50,7 @@ export interface ServerConfig BB_HOST_DAEMON_PORT: number; BB_INHERITED_SKILLS_ROOTS: string[]; BB_INFERENCE: string; + BB_INFERENCE_FALLBACK: string; BB_POSTHOG_API_KEY: string; BB_SERVER_BIND_HOST: ServerBindHost; BB_TELEMETRY: boolean; @@ -135,6 +138,12 @@ export function loadServerConfig( definition: BB_INFERENCE_ENV, env: loader.env, }), + BB_INFERENCE_FALLBACK: readEnvVarWithDefault({ + context: loader.context, + defaultValue: DEFAULT_BB_INFERENCE_FALLBACK, + definition: BB_INFERENCE_FALLBACK_ENV, + env: loader.env, + }), BB_POSTHOG_API_KEY: readEnvVarWithDefault({ context: loader.context, defaultValue: DEFAULT_BB_POSTHOG_API_KEY, diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts index e42b165022..0faf623f09 100644 --- a/packages/config/test/config.test.ts +++ b/packages/config/test/config.test.ts @@ -292,6 +292,7 @@ describe("consumer-specific config", () => { BB_EXTERNAL_URL: undefined, BB_FF_PLACEHOLDER: undefined, BB_INFERENCE: undefined, + BB_INFERENCE_FALLBACK: undefined, BB_TRANSCRIPTION: undefined, }), }); @@ -304,6 +305,7 @@ describe("consumer-specific config", () => { expect(serverConfig.BB_APP_VERSION).toBe("0.0.0-dev"); expect(serverConfig.BB_EXTERNAL_URL).toBe(""); expect(serverConfig.BB_INFERENCE).toBe("codex/gpt-5.6-luna"); + expect(serverConfig.BB_INFERENCE_FALLBACK).toBe("codex/gpt-5.4-mini"); expect(serverConfig.BB_TRANSCRIPTION).toBe("codex/gpt-transcribe"); expect(serverConfig.OPENAI_API_KEY).toBe("test-openai-key"); expect(serverConfig.featureFlags).toEqual({ @@ -468,6 +470,28 @@ describe("consumer-specific config", () => { ).toThrow(/BB_INFERENCE/u); }); + it("requires provider/model format for BB_INFERENCE_FALLBACK", () => { + expect(() => + loadServerConfig({ + env: createServerRuntimeEnv({ + BB_INFERENCE_FALLBACK: "gpt-5.4-mini", + }), + }), + ).toThrow(/BB_INFERENCE_FALLBACK/u); + }); + + it("loads an explicit inference fallback model", () => { + const serverConfig = loadServerConfig({ + env: createServerRuntimeEnv({ + BB_INFERENCE_FALLBACK: "anthropic/claude-haiku-4-5", + }), + }); + + expect(serverConfig.BB_INFERENCE_FALLBACK).toBe( + "anthropic/claude-haiku-4-5", + ); + }); + it("requires provider/model format for BB_TRANSCRIPTION", () => { expect(() => loadServerConfig({ diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index d707d36c03..1757707016 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -39,7 +39,7 @@ export const templateDefinitions = [ }, { "id": "bbGuideCustomization", - "body": "Customization commands\n\nTheming — the app-wide color palette\n\n`bb theme` controls a set of CSS-variable overrides, persisted server-side and\napplied live to every open window. This is the palette only; light/dark mode is a\nseparate per-client setting the palette layers on top of. Custom themes live on\ndisk, one folder per theme, at /theme//theme.css (the packaged\napp uses ~/.bb/theme/…). The folder name is the theme id.\n\n bb theme list Built-in and custom themes; shows the active one\n bb theme dir Print the custom-theme directory (where to author)\n bb theme set [--favicon-color ]\n Activate a theme, preserving the favicon color\n unless the flag supplies the complete selection\n bb theme show [--css] Print the active palette; --css dumps the CSS\n bb theme reset Back to the default theme; preserve favicon color\n bb theme favicon set Set favicon color; preserve the active theme\n bb theme favicon reset Reset favicon color; preserve the active theme\n\nTo author a custom theme, run `bb theme dir`, write //theme.css,\nthen `bb theme set `. The full design-token reference is in the bb-cli\nskill (references/theming.md).\n\nFavicon colors are `default`, `red`, `orange`, `yellow`, `green`, `teal`,\n`blue`, `purple`, and `pink`. Theme and favicon-only commands carry the other\nappearance value forward explicitly.\n\nAdd --json to any theme command for machine-readable output.\n\nPackaged launcher settings\n\n`bb-app config` and `bb-app env` reload runtime settings in a running server,\nbut the CLI identifies server and launcher settings that are startup-only,\nincluding binding/ports, data and the dev-app port, telemetry, inherited skill\nroots, and `BB_FF_*` flags. `BB_LOG_LEVEL` is also startup-only. Use\n`bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, or\n`BB_TRANSCRIPTION` live. After a startup-only change, run `bb-app stop && bb-app\nstart` or restart the desktop app. Until then, changing or unsetting\n`BB_SERVER_BIND_HOST` does not close a previous `0.0.0.0` listener.\n\nServer-backed General settings\n\nSettings → General includes app-wide preferences stored server-side so every\nwindow and restart sees the same value. On macOS, the Caffeinate toggle asks the\nprimary host daemon to run `/usr/bin/caffeinate -i -w `, preventing\nsystem idle sleep while bb is running; turning it off stops that process. It\nonly blocks idle sleep: closing a laptop lid or choosing Sleep manually still\nsleeps the Mac. This setting is only shown when the connected primary host\ndaemon reports macOS.\n\nSettings → Keyboard also includes `showKeyboardHints`, which defaults to true.\nTurn it off to hide the delayed shortcut badges shown while holding Command or\nControl on macOS, or Control on Windows/Linux. Shortcut commands continue to\nwork.\n\nSettings → General includes `showUnhandledProviderEvents`, which defaults to\nfalse in packaged builds. Turn it on to show raw provider events bb does not yet\nunderstand; development builds always show these diagnostic rows.\n\nSettings → General also includes `steerActiveThreadOnEnter`, which defaults to\nfalse. Outside an open typeahead menu, enabling it makes Enter steer a running\nthread and Command+Enter queue a follow-up; when disabled, those actions are\nreversed. Shift+Enter inserts a newline, and unmodified Enter inserts a newline\nin zen mode. On coarse-pointer touch devices, the software-keyboard Return path\ninserts a newline. iPadOS WebKit preserves these Enter shortcuts for a connected\nMagic Keyboard.\n\n bb settings show\n bb settings general \n bb settings replay-onboarding\n bb settings experiment \n bb settings usage [--machine ]\n bb settings version [--force]\n bb settings reload\n\n`bb settings replay-onboarding` enables the `newOnboarding` experiment and\nclears `onboardingCompletedAt`. The first-run setup guide then shows again on\nthe next app load. The same button lives in Settings → General → Setup guide\nwhile the experiment is on.\n\nThe `newOnboarding` experiment exposes the first-run agent and project setup\nguide.\nThe `toolsHub` experiment exposes Extensions for managing skills and plugins.\nAutomations stays in the Plugins section beside threads. It does not enable or\ndisable installed skills, automation execution, plugin runtimes, CLI commands,\nor backend APIs.\n\nThread timeline windows are bounded by event count as well as user-message\ncount (`BB_FF_TIMELINE_WINDOW_EVENT_BUDGET`, default 1500), so a long thread\nstops reprojecting its whole history — and blocking the server event loop — on\nevery update. A turn still running is cut at the budget too, so a very long\nturn costs the budget per update instead of growing without limit. Older\nactivity loads automatically as you scroll toward the top.\n\nServer-backed keyboard shortcuts\n\nSettings → Keyboard records per-command shortcut overrides. They are persisted\nserver-side, applied live to every connected window, and survive restarts.\nReset removes an override and returns to bb's current default; Clear explicitly\ndisables a command. `Mod` means Command on macOS and Control on Windows/Linux.\nBindings for non-native actions apply in browser and desktop clients. Command\ncontexts and native-only availability remain server-owned, and desktop menu\naccelerators for New Thread, New Window, New Tab, Close, and Settings use the\nsame resolved bindings. The complete default table is in docs/configuration.md.\n\n bb settings keyboard list\n bb settings keyboard hints \n bb settings keyboard set \n bb settings keyboard reset [command]\n\nHost files and voice transcription\n\n bb file read|write|list|paths|mkdir|move|remove ...\n bb voice transcribe [--prompt ]\n\nVoice transcription uses the `BB_TRANSCRIPTION` model, which defaults to\n`codex/gpt-transcribe`. Override it with\n`bb-app config set BB_TRANSCRIPTION `.\n\n`bb file` supports `--host` for remote machines and `--root` on mutating\ncommands to confine access beneath an absolute directory. Use `--json` for\nmetadata and machine-readable results.\n\nClient-local UI preferences\n\nSome Settings values live only in the current browser/client. The Voice Input\nmicrophone picker stores the selected browser MediaDevices device id in\nlocalStorage as `bb.voiceInput.audioInputDeviceId`; it does not have a `bb`\ncommand and does not change the server-side transcription model.", + "body": "Customization commands\n\nTheming — the app-wide color palette\n\n`bb theme` controls a set of CSS-variable overrides, persisted server-side and\napplied live to every open window. This is the palette only; light/dark mode is a\nseparate per-client setting the palette layers on top of. Custom themes live on\ndisk, one folder per theme, at /theme//theme.css (the packaged\napp uses ~/.bb/theme/…). The folder name is the theme id.\n\n bb theme list Built-in and custom themes; shows the active one\n bb theme dir Print the custom-theme directory (where to author)\n bb theme set [--favicon-color ]\n Activate a theme, preserving the favicon color\n unless the flag supplies the complete selection\n bb theme show [--css] Print the active palette; --css dumps the CSS\n bb theme reset Back to the default theme; preserve favicon color\n bb theme favicon set Set favicon color; preserve the active theme\n bb theme favicon reset Reset favicon color; preserve the active theme\n\nTo author a custom theme, run `bb theme dir`, write //theme.css,\nthen `bb theme set `. The full design-token reference is in the bb-cli\nskill (references/theming.md).\n\nFavicon colors are `default`, `red`, `orange`, `yellow`, `green`, `teal`,\n`blue`, `purple`, and `pink`. Theme and favicon-only commands carry the other\nappearance value forward explicitly.\n\nAdd --json to any theme command for machine-readable output.\n\nPackaged launcher settings\n\n`bb-app config` and `bb-app env` reload runtime settings in a running server,\nbut the CLI identifies server and launcher settings that are startup-only,\nincluding binding/ports, data and the dev-app port, telemetry, inherited skill\nroots, and `BB_FF_*` flags. `BB_LOG_LEVEL` is also startup-only. Use\n`bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`,\n`BB_INFERENCE_FALLBACK`, or `BB_TRANSCRIPTION` live. After a startup-only\nchange, run `bb-app stop && bb-app start` or restart the desktop app. Until\nthen, changing or unsetting `BB_SERVER_BIND_HOST` does not close a previous\n`0.0.0.0` listener.\n\nServer helper completions use `BB_INFERENCE` first, then\n`BB_INFERENCE_FALLBACK` after a transient timeout, rate limit, or\nservice-unavailable failure. Their defaults are `codex/gpt-5.6-luna` and\n`codex/gpt-5.4-mini`, respectively.\n\n bb-app config set BB_INFERENCE \n bb-app config set BB_INFERENCE_FALLBACK \n\nServer-backed General settings\n\nSettings → General includes app-wide preferences stored server-side so every\nwindow and restart sees the same value. On macOS, the Caffeinate toggle asks the\nprimary host daemon to run `/usr/bin/caffeinate -i -w `, preventing\nsystem idle sleep while bb is running; turning it off stops that process. It\nonly blocks idle sleep: closing a laptop lid or choosing Sleep manually still\nsleeps the Mac. This setting is only shown when the connected primary host\ndaemon reports macOS.\n\nSettings → Keyboard also includes `showKeyboardHints`, which defaults to true.\nTurn it off to hide the delayed shortcut badges shown while holding Command or\nControl on macOS, or Control on Windows/Linux. Shortcut commands continue to\nwork.\n\nSettings → General includes `showUnhandledProviderEvents`, which defaults to\nfalse in packaged builds. Turn it on to show raw provider events bb does not yet\nunderstand; development builds always show these diagnostic rows.\n\nSettings → General also includes `steerActiveThreadOnEnter`, which defaults to\nfalse. Outside an open typeahead menu, enabling it makes Enter steer a running\nthread and Command+Enter queue a follow-up; when disabled, those actions are\nreversed. Shift+Enter inserts a newline, and unmodified Enter inserts a newline\nin zen mode. On coarse-pointer touch devices, the software-keyboard Return path\ninserts a newline. iPadOS WebKit preserves these Enter shortcuts for a connected\nMagic Keyboard.\n\n bb settings show\n bb settings general \n bb settings replay-onboarding\n bb settings experiment \n bb settings usage [--machine ]\n bb settings version [--force]\n bb settings reload\n\n`bb settings replay-onboarding` enables the `newOnboarding` experiment and\nclears `onboardingCompletedAt`. The first-run setup guide then shows again on\nthe next app load. The same button lives in Settings → General → Setup guide\nwhile the experiment is on.\n\nThe `newOnboarding` experiment exposes the first-run agent and project setup\nguide.\nThe `toolsHub` experiment exposes Extensions for managing skills and plugins.\nAutomations stays in the Plugins section beside threads. It does not enable or\ndisable installed skills, automation execution, plugin runtimes, CLI commands,\nor backend APIs.\n\nThread timeline windows are bounded by event count as well as user-message\ncount (`BB_FF_TIMELINE_WINDOW_EVENT_BUDGET`, default 1500), so a long thread\nstops reprojecting its whole history — and blocking the server event loop — on\nevery update. A turn still running is cut at the budget too, so a very long\nturn costs the budget per update instead of growing without limit. Older\nactivity loads automatically as you scroll toward the top.\n\nServer-backed keyboard shortcuts\n\nSettings → Keyboard records per-command shortcut overrides. They are persisted\nserver-side, applied live to every connected window, and survive restarts.\nReset removes an override and returns to bb's current default; Clear explicitly\ndisables a command. `Mod` means Command on macOS and Control on Windows/Linux.\nBindings for non-native actions apply in browser and desktop clients. Command\ncontexts and native-only availability remain server-owned, and desktop menu\naccelerators for New Thread, New Window, New Tab, Close, and Settings use the\nsame resolved bindings. The complete default table is in docs/configuration.md.\n\n bb settings keyboard list\n bb settings keyboard hints \n bb settings keyboard set \n bb settings keyboard reset [command]\n\nHost files and voice transcription\n\n bb file read|write|list|paths|mkdir|move|remove ...\n bb voice transcribe [--prompt ]\n\nVoice transcription uses the `BB_TRANSCRIPTION` model, which defaults to\n`codex/gpt-transcribe`. Override it with\n`bb-app config set BB_TRANSCRIPTION `.\n\n`bb file` supports `--host` for remote machines and `--root` on mutating\ncommands to confine access beneath an absolute directory. Use `--json` for\nmetadata and machine-readable results.\n\nClient-local UI preferences\n\nSome Settings values live only in the current browser/client. The Voice Input\nmicrophone picker stores the selected browser MediaDevices device id in\nlocalStorage as `bb.voiceInput.audioInputDeviceId`; it does not have a `bb`\ncommand and does not change the server-side transcription model.", "fileName": "bb-guide-customization.md", "kind": "instruction", "title": "bb Guide — Customization", diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index a03250c901..5486f154c1 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -41,10 +41,19 @@ Packaged launcher settings but the CLI identifies server and launcher settings that are startup-only, including binding/ports, data and the dev-app port, telemetry, inherited skill roots, and `BB_FF_*` flags. `BB_LOG_LEVEL` is also startup-only. Use -`bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, or -`BB_TRANSCRIPTION` live. After a startup-only change, run `bb-app stop && bb-app -start` or restart the desktop app. Until then, changing or unsetting -`BB_SERVER_BIND_HOST` does not close a previous `0.0.0.0` listener. +`bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, +`BB_INFERENCE_FALLBACK`, or `BB_TRANSCRIPTION` live. After a startup-only +change, run `bb-app stop && bb-app start` or restart the desktop app. Until +then, changing or unsetting `BB_SERVER_BIND_HOST` does not close a previous +`0.0.0.0` listener. + +Server helper completions use `BB_INFERENCE` first, then +`BB_INFERENCE_FALLBACK` after a transient timeout, rate limit, or +service-unavailable failure. Their defaults are `codex/gpt-5.6-luna` and +`codex/gpt-5.4-mini`, respectively. + + bb-app config set BB_INFERENCE + bb-app config set BB_INFERENCE_FALLBACK Server-backed General settings From 61eb88c3cb2d1bc39c9730d3eb6ad1410e77b8e8 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 11 Aug 2026 10:22:37 -0700 Subject: [PATCH 3/5] Handle terminal Codex SSE failures immediately --- .../src/codex-chatgpt-client.test.ts | 100 ++++++++++++++++++ apps/host-daemon/src/codex-chatgpt-client.ts | 67 +++++++----- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/apps/host-daemon/src/codex-chatgpt-client.test.ts b/apps/host-daemon/src/codex-chatgpt-client.test.ts index ab0344ebb3..17f100ccdb 100644 --- a/apps/host-daemon/src/codex-chatgpt-client.test.ts +++ b/apps/host-daemon/src/codex-chatgpt-client.test.ts @@ -128,6 +128,35 @@ function stalledSseResponse(): Response { }); } +function openSseResponse(events: JsonValue[]): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const bytes = new TextEncoder().encode( + `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`, + ); + return { + response: new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + }, + cancel() { + canceled = true; + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ), + wasCanceled: () => canceled, + }; +} + function delayedSseResponse(delayMs: number, events: JsonValue[]): Response { const bytes = new TextEncoder().encode( `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\ndata: [DONE]\n\n`, @@ -376,6 +405,77 @@ describe("Codex ChatGPT client", () => { }); }); + it("preserves structured server error codes from failed responses", async () => { + const homeDir = await makeTempHome(); + await writeCodexApiKeyAuth({ + homeDir, + apiKey: "sk-codex-api-key", + }); + const fetchMock = setupFetchMock(); + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: "response.failed", + response: { + error: { + code: "server_error", + message: "An unexpected provider error occurred.", + }, + }, + }, + ]), + ); + + await expect( + completeCodexInference({ + type: "codex.inference.complete", + model: "gpt-5.6-luna", + reasoningEffort: "none", + prompt: "Return a title", + outputSchema: { type: "object" }, + timeoutMs: 10_000, + }), + ).rejects.toMatchObject({ + code: "codex_service_unavailable", + message: "An unexpected provider error occurred.", + }); + }); + + it("cancels an open SSE body after a terminal failure event", async () => { + const homeDir = await makeTempHome(); + await writeCodexApiKeyAuth({ + homeDir, + apiKey: "sk-codex-api-key", + }); + const fetchMock = setupFetchMock(); + const failedResponse = openSseResponse([ + { + type: "response.failed", + response: { + error: { + code: "server_error", + message: "An unexpected provider error occurred.", + }, + }, + }, + ]); + fetchMock.mockResolvedValueOnce(failedResponse.response); + + await expect( + completeCodexInference({ + type: "codex.inference.complete", + model: "gpt-5.6-luna", + reasoningEffort: "none", + prompt: "Return a title", + outputSchema: { type: "object" }, + timeoutMs: 100, + }), + ).rejects.toMatchObject({ + code: "codex_service_unavailable", + }); + expect(failedResponse.wasCanceled()).toBe(true); + }); + it("uses Codex auth read-only without refreshing expired-looking access tokens", async () => { const homeDir = await makeTempHome(); const oldAccessToken = createAccessToken({ diff --git a/apps/host-daemon/src/codex-chatgpt-client.ts b/apps/host-daemon/src/codex-chatgpt-client.ts index 989d912958..3889ce2529 100644 --- a/apps/host-daemon/src/codex-chatgpt-client.ts +++ b/apps/host-daemon/src/codex-chatgpt-client.ts @@ -145,8 +145,13 @@ interface CodexInputContent { } interface ResponseTextResult { + failure: CodexStreamFailure | null; text: string; - failedMessage: string | null; +} + +interface CodexStreamFailure { + code: string | null; + message: string; } function jsonObject(value: JsonValue): JsonObject | null { @@ -392,8 +397,14 @@ function codexRequestErrorCode(status: number): string { const CODEX_SERVICE_UNAVAILABLE_PATTERN = /\b(?:overloaded|temporarily unavailable|try again later)\b/iu; -function codexStreamFailureErrorCode(message: string): string { - return CODEX_SERVICE_UNAVAILABLE_PATTERN.test(message) +function codexStreamFailureErrorCode(failure: CodexStreamFailure): string { + if (failure.code === "server_error") { + return "codex_service_unavailable"; + } + if (failure.code === "rate_limit_exceeded") { + return "codex_rate_limited"; + } + return CODEX_SERVICE_UNAVAILABLE_PATTERN.test(failure.message) ? "codex_service_unavailable" : "codex_request_failed"; } @@ -488,56 +499,65 @@ function getCodexResponseText(response: JsonObject): string | null { return null; } -function getCodexFailureMessage(response: JsonObject): string | null { +function getCodexFailure(response: JsonObject): CodexStreamFailure | null { const error = response.error ? jsonObject(response.error) : null; if (!error) { return null; } - return optionalString(error.message) ?? optionalString(error.code); + const code = optionalString(error.code); + return { + code, + message: optionalString(error.message) ?? code ?? "Codex response failed", + }; } function extractTextFromSseEvent(event: JsonObject): ResponseTextResult { const type = optionalString(event.type); if (type === "error") { + const code = optionalString(event.code); return { + failure: { + code, + message: + optionalString(event.message) ?? code ?? "Codex response failed", + }, text: "", - failedMessage: - optionalString(event.message) ?? - optionalString(event.code) ?? - "Codex response failed", }; } if (type === "response.failed") { const response = event.response ? jsonObject(event.response) : null; return { + failure: response + ? (getCodexFailure(response) ?? { + code: null, + message: "Codex response failed", + }) + : { code: null, message: "Codex response failed" }, text: "", - failedMessage: response - ? (getCodexFailureMessage(response) ?? "Codex response failed") - : "Codex response failed", }; } if (type === "response.output_text.delta") { return { + failure: null, text: optionalString(event.delta) ?? "", - failedMessage: null, }; } if (type === "response.completed" || type === "response.done") { const response = event.response ? jsonObject(event.response) : null; const text = response ? getCodexResponseText(response) : null; - const failedMessage = response ? getCodexFailureMessage(response) : null; + const failure = response ? getCodexFailure(response) : null; return { + failure, text: text ?? "", - failedMessage, }; } return { + failure: null, text: "", - failedMessage: null, }; } @@ -568,7 +588,6 @@ async function readResponseTextFromSse( let buffer = ""; let deltaText = ""; let finalText: string | null = null; - let failedMessage: string | null = null; let totalBytes = 0; try { @@ -609,8 +628,11 @@ async function readResponseTextFromSse( const event = jsonObject(eventValue); if (event) { const result = extractTextFromSseEvent(event); - if (result.failedMessage) { - failedMessage = result.failedMessage; + if (result.failure) { + throw new ExpectedCommandDispatchError( + codexStreamFailureErrorCode(result.failure), + result.failure.message, + ); } if (result.text) { if ( @@ -633,13 +655,6 @@ async function readResponseTextFromSse( throw error; } - if (failedMessage) { - throw new ExpectedCommandDispatchError( - codexStreamFailureErrorCode(failedMessage), - failedMessage, - ); - } - const text = finalText ?? deltaText; if (!text) { throw new ExpectedCommandDispatchError( From d9431de4a00b7a8a863190c9a1cbdf85b02a9fb9 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 11 Aug 2026 10:26:57 -0700 Subject: [PATCH 4/5] Add fallback model to integration harness --- tests/integration/helpers/harness.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/helpers/harness.ts b/tests/integration/helpers/harness.ts index 3ae15a8822..427ef67275 100644 --- a/tests/integration/helpers/harness.ts +++ b/tests/integration/helpers/harness.ts @@ -228,6 +228,7 @@ async function startIntegrationServer( dataDir: serverDataDir, featureFlags: defaultFeatureFlags, hostDaemonPort: 3001, + inferenceFallbackModel: "test/mock-fallback-model", inferenceModel: "test/mock-model", inheritedSkillsRootPaths: [], openAiApiKey: process.env.OPENAI_API_KEY ?? "test-openai-key", From e9e28bb8b7321f309c4f283a51994af60eca42c2 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 11 Aug 2026 10:56:55 -0700 Subject: [PATCH 5/5] Apply inference fallback to async thread titles --- .../threads/thread-metadata-inference.ts | 19 ++++---- .../thread-provisioning-environment.ts | 10 +---- .../threads/generated-branch-names.test.ts | 43 ++++++++++++++----- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/apps/server/src/services/threads/thread-metadata-inference.ts b/apps/server/src/services/threads/thread-metadata-inference.ts index 0d02a0cc4d..9fc262d1d5 100644 --- a/apps/server/src/services/threads/thread-metadata-inference.ts +++ b/apps/server/src/services/threads/thread-metadata-inference.ts @@ -10,11 +10,12 @@ import { runtimeErrorLogFields } from "../lib/error-log-fields.js"; type ThreadMetadataInferenceDeps = LoggedWorkSessionDeps; -// Luna commonly needs more than 2.5s for structured output. Two 5s attempts -// let the first ordinary completion finish and still recover a transient -// timeout or service-unavailable response. -export const MANAGED_THREAD_METADATA_TIMEOUT_MS = 5_000; -export const MANAGED_THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS = 2; +// Luna commonly needs more than 2.5s for structured output. Every thread title +// gets one primary attempt and one fallback attempt after a transient failure. +// Non-managed title generation remains asynchronous, so this retry budget does +// not delay thread startup. +const THREAD_METADATA_TIMEOUT_MS = 5_000; +const THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS = 2; export interface ThreadMetadataInferenceArgs { environmentId: string | null; @@ -22,8 +23,6 @@ export interface ThreadMetadataInferenceArgs { generateTitle: boolean; input: PromptInput[]; provisioningId: string | null; - timeoutMaxAttempts?: number; - timeoutMs?: number; threadId: string; writeTranscript: boolean; } @@ -140,10 +139,8 @@ export async function inferThreadMetadata( const outcome = await generateThreadMetadataWithOutcome(deps, { input: args.input, threadId: args.threadId, - ...(args.timeoutMaxAttempts !== undefined - ? { timeoutMaxAttempts: args.timeoutMaxAttempts } - : {}), - ...(args.timeoutMs ? { timeoutMs: args.timeoutMs } : {}), + timeoutMaxAttempts: THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS, + timeoutMs: THREAD_METADATA_TIMEOUT_MS, }); if (transcriptEnvironmentId && provisioningId) { diff --git a/apps/server/src/services/threads/thread-provisioning-environment.ts b/apps/server/src/services/threads/thread-provisioning-environment.ts index 7ea143ad8e..0fc89e677c 100644 --- a/apps/server/src/services/threads/thread-provisioning-environment.ts +++ b/apps/server/src/services/threads/thread-provisioning-environment.ts @@ -38,11 +38,7 @@ import { type UnmanagedCheckoutCommand, } from "./thread-create-helpers.js"; import { dispatchThreadRenameCommand } from "./thread-commands.js"; -import { - inferThreadMetadata, - MANAGED_THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS, - MANAGED_THREAD_METADATA_TIMEOUT_MS, -} from "./thread-metadata-inference.js"; +import { inferThreadMetadata } from "./thread-metadata-inference.js"; import { deriveBranchSlugFromTitle } from "./title-generation.js"; import { attachedEnvironmentIdForContext, @@ -437,8 +433,6 @@ async function resolveMetadataIfNeeded( input: args.context.request.input, provisioningId: args.context.state.provisioningId, threadId: args.thread.id, - timeoutMaxAttempts: MANAGED_THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS, - timeoutMs: MANAGED_THREAD_METADATA_TIMEOUT_MS, writeTranscript: true, }); @@ -540,8 +534,6 @@ async function resolveMetadataIfNeeded( input: args.context.request.input, provisioningId: args.context.state.provisioningId, threadId: args.thread.id, - timeoutMaxAttempts: MANAGED_THREAD_METADATA_TIMEOUT_MAX_ATTEMPTS, - timeoutMs: MANAGED_THREAD_METADATA_TIMEOUT_MS, writeTranscript: false, }); diff --git a/apps/server/test/threads/generated-branch-names.test.ts b/apps/server/test/threads/generated-branch-names.test.ts index 1412cfff3c..6b580e7d53 100644 --- a/apps/server/test/threads/generated-branch-names.test.ts +++ b/apps/server/test/threads/generated-branch-names.test.ts @@ -776,19 +776,28 @@ describe("generated managed branch names", () => { }); }); - it("renames an idle non-managed thread when its generated title lands late", async () => { + it("uses the fallback model and renames an idle non-managed thread", async () => { let resolveMetadata: (metadata: MockThreadMetadata) => void = () => { throw new Error("Metadata inference was not started"); }; piAiMocks.getModel.mockReturnValue({ provider: "test" }); - piAiMocks.complete.mockImplementation( - () => - new Promise((resolve) => { - resolveMetadata = (metadata) => { - resolve(mockThreadMetadataCompletion(metadata)); - }; - }), - ); + piAiMocks.complete + .mockRejectedValueOnce( + new ApiError( + 502, + "codex_service_unavailable", + "Our servers are currently overloaded. Please try again later.", + false, + ), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMetadata = (metadata) => { + resolve(mockThreadMetadataCompletion(metadata)); + }; + }), + ); await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps, { @@ -884,7 +893,11 @@ describe("generated managed branch names", () => { expect(eventsResponse.status).toBe(200); expect(getThread(harness.db, thread.id)?.status).toBe("idle"); - // The generated title lands only now, while the thread is idle. + await vi.waitFor(() => { + expect(piAiMocks.complete).toHaveBeenCalledTimes(2); + }); + + // The fallback title lands only now, while the thread is idle. resolveMetadata({ title: "Late Idle Title" }); const rename = await waitForQueuedCommandAfter( @@ -899,6 +912,16 @@ describe("generated managed branch names", () => { title: "Late Idle Title", }); expect(getThread(harness.db, thread.id)?.title).toBe("Late Idle Title"); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 1, + "test", + "mock-model", + ); + expect(piAiMocks.getModel).toHaveBeenNthCalledWith( + 2, + "test", + "mock-fallback-model", + ); }); });