diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index c7106f0cff28..8292121ac2ce 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -5,6 +5,8 @@ import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { LAST_USED_PROJECT_STORAGE_KEY } from "../hooks/useHandleNewThread"; +import { removeLocalStorageItem } from "../hooks/useLocalStorage"; import { makeThreadFixture } from "../test-fixtures"; import type { Project, Thread } from "../types"; import { CommandPalette } from "./CommandPalette"; @@ -20,6 +22,9 @@ vi.mock("../state/entities", async (original) => ({ })); function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArray } = {}) { + // Earlier browser test files share this origin's localStorage; a remembered project + // that is not listed here hides the default "New thread in " row. + removeLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY); useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); entities.projects = state.projects ?? []; entities.threads = state.threads ?? []; diff --git a/packages/provider-muse/src/server/textGeneration.test.ts b/packages/provider-muse/src/server/textGeneration.test.ts index 7876cac59a50..f0b1f1a300ad 100644 --- a/packages/provider-muse/src/server/textGeneration.test.ts +++ b/packages/provider-muse/src/server/textGeneration.test.ts @@ -226,6 +226,62 @@ it.layer(NodeServices.layer)("Muse text generation", (it) => { }), ); + it.effect("explains a failed turn by Muse's error category", () => + Effect.gen(function* () { + const message = "API error 429: raw provider text"; + const cases = [ + { + error: { kind: "modelError", quota: { limitKind: "tierLimit", resetsAtMs: 1 } }, + detail: "Muse Code usage limit reached. Try again after it resets.", + }, + { + // Credential proxies can refuse with a 429 quota message and no quota object. + error: { kind: "modelError", message: "upstream 429: quota exceeded for this key" }, + detail: "Muse Code usage limit reached. Try again after it resets.", + }, + { + error: { kind: "authRequired" }, + detail: "Muse Code is not signed in. Run `muse login` on this T3 server host.", + }, + { + error: { kind: "modelError" }, + detail: "Muse Code's model request failed. Try again or choose another Muse model.", + }, + { + error: { kind: "stepLimit" }, + detail: + "Muse Code text generation failed. Check Muse login and availability on this T3 server host.", + }, + { + reason: "upstream 429: quota exceeded for this key", + detail: "Muse Code usage limit reached. Try again after it resets.", + }, + { + reason: "raw provider refusal", + detail: + "Muse Code text generation failed. Check Muse login and availability on this T3 server host.", + }, + ]; + for (const { error, reason, detail } of cases) { + const raw = error === undefined ? reason : "message" in error ? error.message : message; + const test = fixture((emit) => + emit("turn/completed", { + terminal: "failed", + ...(error === undefined + ? { reason } + : { error: { message, ...error, retryable: false } }), + }), + ); + const service = yield* test.make; + const failure = yield* service.generateThreadTitle(titleInput).pipe(Effect.flip); + expect(failure).toMatchObject({ _tag: "TextGenerationError", detail }); + expect(failure.detail).not.toContain(raw); + expect(failure.cause).toMatchObject({ cause: { message: raw } }); + expect(test.host.close).toHaveBeenCalledOnce(); + } + }), + ); + it.effect("settles a completed turn even when its start acknowledgement never arrives", () => Effect.gen(function* () { const test = fixture(finish('{"title":"Fix login form"}'), false); diff --git a/packages/provider-muse/src/server/textGeneration.ts b/packages/provider-muse/src/server/textGeneration.ts index e36f4de9fbd1..dacaeae8bd03 100644 --- a/packages/provider-muse/src/server/textGeneration.ts +++ b/packages/provider-muse/src/server/textGeneration.ts @@ -21,6 +21,7 @@ import { type MuseSdkHost, } from "./sdk.ts"; import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; +import { isMuseUsageLimitFailure } from "./usageLimits.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { generatedThreadTitle, @@ -52,11 +53,17 @@ const ItemNotification = Schema.Struct({ truncated: Schema.optionalKey(Schema.Boolean), }), }); +const TurnError = Schema.Struct({ + kind: Schema.optionalKey(Schema.String), + message: Schema.String, + quota: Schema.optionalKey(Schema.Unknown), +}); const TurnCompleted = Schema.Struct({ sessionId: Schema.String, turnId: Schema.String, terminal: Schema.String, - error: Schema.optionalKey(Schema.Struct({ message: Schema.String })), + reason: Schema.optionalKey(Schema.String), + error: Schema.optionalKey(TurnError), }); const ApprovalRequested = Schema.Struct({ sessionId: Schema.String, @@ -75,10 +82,32 @@ const decodeItem = Schema.decodeUnknownSync(ItemNotification); const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted); const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested); const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); +const isMuseSdkError = Schema.is(MuseSdkError); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); type Operation = keyof ProviderTextGeneration; +class MuseTurnFailure extends Schema.TaggedError()("MuseTurnFailure", { + turnError: TurnError, +}) { + override get message() { + return this.turnError.message; + } +} +const isMuseTurnFailure = Schema.is(MuseTurnFailure); + +function museFailureDetail(cause: unknown): string { + const error = + isMuseSdkError(cause) && isMuseTurnFailure(cause.cause) ? cause.cause.turnError : undefined; + // Muse sends `quota` only for quota exhaustion, which it still labels `modelError`. + if (error?.quota !== undefined || isMuseUsageLimitFailure(error?.message)) + return "Muse Code usage limit reached. Try again after it resets."; + if (error?.kind === "authRequired") + return "Muse Code is not signed in. Run `muse login` on this T3 server host."; + if (error?.kind === "modelError") + return "Muse Code's model request failed. Try again or choose another Muse model."; + return "Muse Code text generation failed. Check Muse login and availability on this T3 server host."; +} async function generateMuseText( host: MuseSdkHost, @@ -144,8 +173,13 @@ async function generateMuseText( resolveCompletion(answer?.text ?? ""); } } else { + const error = + completed.error ?? + (completed.reason !== undefined ? { message: completed.reason } : undefined); rejectCompletion( - new Error(completed.error?.message ?? `Muse turn ${completed.terminal}.`), + error + ? new MuseTurnFailure({ turnError: error }) + : new Error(`Muse turn ${completed.terminal}.`), ); } } else if ( @@ -276,8 +310,7 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi (cause) => new TextGenerationError({ operation, - detail: - "Muse Code text generation failed. Check Muse login and availability on this T3 server host.", + detail: museFailureDetail(cause), cause, }), ),